D2业务员H5+移动模拟全绿 / P2-2部署清单+ALLOWED_HOSTS通配符门 / P3-1跳过

This commit is contained in:
seagull
2026-09-12 12:18:37 +08:00
parent 6c0693d160
commit eef7be7258
6 changed files with 1023 additions and 1 deletions
+630
View File
@@ -0,0 +1,630 @@
<template>
<div class="sm-wrap">
<!-- 登录(业务员 = 后台用户 JWT,与 PC 端同一套账号) -->
<div v-if="!token" class="sm-login">
<h2>业务员开单</h2>
<p class="sm-sub">外勤开单 / 查欠款 / 收款登记,用公司账号登录</p>
<el-form :model="login" label-position="top" @keyup.enter="doLogin">
<el-form-item label="用户名">
<el-input v-model="login.username" placeholder="如 alice" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="login.password" type="password" show-password />
</el-form-item>
<el-form-item label="租户编码">
<el-input v-model="login.tenant" placeholder="如 default" />
</el-form-item>
<el-button type="primary" style="width: 100%" :loading="loading" @click="doLogin">
登 录
</el-button>
</el-form>
</div>
<!-- 主界面 -->
<template v-else>
<div class="sm-head">
<div>
<div class="sm-shop">业务员开单</div>
<div class="sm-sub">已登录 {{ username }} · {{ tenant }}</div>
</div>
<el-button text size="small" @click="logout">退出</el-button>
</div>
<el-tabs v-model="tab">
<!-- 外勤开单 -->
<el-tab-pane label="开单" name="bill">
<el-form label-position="top" size="small">
<el-form-item label="客户" required>
<el-select
v-model="form.customer" filterable placeholder="选择客户"
style="width: 100%" @change="onCustomerChange"
>
<el-option
v-for="c in customers" :key="c.id"
:label="`${c.code} ${c.name}`" :value="c.id"
/>
</el-select>
</el-form-item>
<el-form-item label="仓库" required>
<el-select v-model="form.warehouse" placeholder="选择仓库" style="width: 100%">
<el-option
v-for="w in warehouses" :key="w.id"
:label="`${w.code} ${w.name}`" :value="w.id"
/>
</el-select>
</el-form-item>
</el-form>
<!-- 信用额度条(复用 SalesBills 取价页同口径) -->
<div v-if="Number(credit.limit) > 0" class="credit-bar">
<span class="credit-label">
信用:已用 ¥{{ credit.outstanding }} / 共 ¥{{ credit.limit }}
</span>
<el-progress
:percentage="creditPercent" :status="creditStatus" :stroke-width="10"
style="flex: 1; min-width: 120px"
/>
</div>
<div class="sm-search">
<el-input
v-model="search" placeholder="搜索商品名/编码" clearable
@keyup.enter="filterProducts" @clear="filterProducts"
>
<template #append><el-button @click="filterProducts">搜</el-button></template>
</el-input>
</div>
<div class="sm-list" v-loading="catalogLoading">
<div v-for="p in visibleProducts" :key="p.id" class="sm-item">
<div class="sm-item-main">
<div class="sm-name">{{ p.name }}</div>
<div class="sm-meta">{{ p.code }} | {{ p.spec || '—' }}</div>
<div class="sm-price">
¥ {{ lineOf(p).price.toFixed(2) }}
<span class="sm-price-unit">/ {{ lineOf(p).unitName }}</span>
<el-tag v-if="lineOf(p).source" size="small" type="info" effect="plain">
{{ sourceText(lineOf(p).source) }}
</el-tag>
</div>
<div v-if="lineOf(p).taxRate > 0" class="sm-tax">
税率 {{ (lineOf(p).taxRate * 100).toFixed(0) }}% · 税 ¥{{ lineTax(p) }}
</div>
</div>
<div class="sm-item-ops">
<el-input-number
v-model="lineOf(p).qty" :min="0" size="small"
:controls="false" style="width: 64px"
/>
<el-select
v-model="lineOf(p).unitId" size="small" style="width: 84px"
@change="() => onUnitChange(p)"
>
<el-option
v-for="u in (lineOf(p).units || [])" :key="u.unit_id"
:label="u.unit_name" :value="u.unit_id"
/>
</el-select>
<el-button size="small" type="primary" @click="addLine(p)">加</el-button>
</div>
</div>
<el-empty v-if="!catalogLoading && !visibleProducts.length" description="暂无商品" />
</div>
<!-- 当前单据 -->
<div v-if="order.length" class="sm-order">
<div class="sm-order-title">当前单据({{ order.length }} 行)</div>
<div v-for="(r, i) in order" :key="i" class="sm-order-row">
<span class="sm-order-name">{{ r.name }} × {{ r.qty }}{{ r.unitName }}</span>
<span>¥ {{ (r.qty * r.price).toFixed(2) }}</span>
<el-button link type="danger" size="small" @click="order.splice(i, 1)">删</el-button>
</div>
<div class="sm-totals">
<span>行合计 ¥ {{ linesTotal.toFixed(2) }}</span>
<span v-if="taxTotal > 0" class="sm-tax-total">其中税 ¥{{ taxTotal.toFixed(2) }}</span>
</div>
<div class="sm-totals">
<span>抹零</span>
<el-select v-model="form.round_to" size="small" style="width: 120px">
<el-option label="不抹零" value="" />
<el-option label="抹分(0.01)" value="0.01" />
<el-option label="抹角(0.1)" value="0.1" />
<el-option label="抹元(1)" value="1" />
</el-select>
<span v-if="roundOffAmount > 0" class="sm-round">- ¥{{ roundOffAmount.toFixed(2) }}</span>
<b class="sm-net">应收 ¥{{ netTotal.toFixed(2) }}</b>
</div>
<el-button
type="primary" style="width: 100%; margin-top: 8px"
:loading="submitting" @click="submitBill"
>
保存并过账
</el-button>
</div>
</el-tab-pane>
<!-- 查欠款 -->
<el-tab-pane label="欠款" name="debt">
<el-form label-position="top" size="small">
<el-form-item label="客户">
<el-select
v-model="debtCustomer" filterable clearable placeholder="全部客户"
style="width: 100%" @change="loadDebts"
>
<el-option
v-for="c in customers" :key="c.id"
:label="`${c.code} ${c.name}`" :value="c.id"
/>
</el-select>
</el-form-item>
</el-form>
<div class="sm-debt-sum" v-if="debts.length">
未结 <b>¥ {{ debtTotal.toFixed(2) }}</b>({{ debts.length }} 单)
</div>
<div class="sm-list" v-loading="debtsLoading">
<div v-for="d in debts" :key="d.id" class="sm-debt">
<div class="sm-item-main">
<div class="sm-name">{{ d.bill_no }}</div>
<div class="sm-meta">{{ d.customer_name }} | {{ d.bill_date }}</div>
<div class="sm-price">
未结 ¥ {{ Number(d.balance).toFixed(2) }}
<span class="sm-price-unit">(共 ¥{{ Number(d.total_amount).toFixed(2) }},已收 ¥{{ Number(d.paid_amount).toFixed(2) }})</span>
</div>
</div>
<el-tag :type="d.status === 'paid' ? 'success' : d.status === 'partial' ? 'warning' : 'danger'" effect="plain" size="small">
{{ debtStatusText(d.status) }}
</el-tag>
</div>
<el-empty v-if="!debtsLoading && !debts.length" description="该客户暂无欠款" />
</div>
</el-tab-pane>
<!-- 收款登记 -->
<el-tab-pane label="收款" name="receipt">
<el-form :model="receipt" label-position="top" size="small" :rules="receiptRules" ref="receiptRef">
<el-form-item label="客户" prop="customer" required>
<el-select v-model="receipt.customer" filterable placeholder="选择客户" style="width: 100%">
<el-option
v-for="c in customers" :key="c.id"
:label="`${c.code} ${c.name}`" :value="c.id"
/>
</el-select>
</el-form-item>
<el-form-item label="收款单号" prop="bill_no" required>
<el-input v-model="receipt.bill_no" placeholder="如 SK202609120001(租户内唯一)" maxlength="64" />
</el-form-item>
<el-form-item label="收款日期" prop="bill_date" required>
<el-date-picker v-model="receipt.bill_date" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
<el-form-item label="收款金额" prop="amount" required>
<el-input-number v-model="receipt.amount" :precision="2" :min="0" style="width: 100%" />
</el-form-item>
<el-form-item label="收款方式">
<el-select v-model="receipt.method" placeholder="现金/银行/微信/支付宝" clearable style="width: 100%">
<el-option label="现金" value="现金" />
<el-option label="银行" value="银行" />
<el-option label="微信" value="微信" />
<el-option label="支付宝" value="支付宝" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="receipt.remark" type="textarea" :rows="2" maxlength="500" />
</el-form-item>
<el-button type="primary" style="width: 100%" :loading="receiptSaving" @click="saveReceipt">
登记收款
</el-button>
</el-form>
</el-tab-pane>
</el-tabs>
</template>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '../api/client'
import {
unitRate,
isValidQuantity as validateQuantity,
isValidPrice as validatePrice,
lineAmount,
} from '../utils/transaction'
const token = ref(localStorage.getItem('token') || '')
const username = ref(localStorage.getItem('username') || '')
const tenant = ref(localStorage.getItem('tenant') || 'default')
const loading = ref(false)
const tab = ref('bill')
const login = reactive({
username: localStorage.getItem('username') || '',
password: '',
tenant: localStorage.getItem('tenant') || 'default',
})
const customers = ref([])
const warehouses = ref([])
const products = ref([])
const visibleProducts = ref([])
const search = ref('')
const catalogLoading = ref(false)
const submitting = ref(false)
const credit = ref({})
const form = reactive({
customer: null, warehouse: null, round_to: '',
})
/** productId → {qty, unitId, unitName, units, price, source, taxRate}(模板纯读,无副作用) */
const lineState = reactive({})
const order = ref([])
const sourceMap = {
customer: '客户专属价', level: '等级价', last: '上次成交价', default: '默认售价',
}
const sourceText = (s) => sourceMap[s] || s
async function doLogin() {
if (!login.username || !login.password) { ElMessage.warning('请输入用户名与密码'); return }
if (!login.tenant?.trim()) { ElMessage.warning('请输入租户编码'); return }
loading.value = true
try {
// api client 的 baseURL 已是 /api/v1,此处用相对路径(与 Login.vue 的全路径等价)
const data = await api.post('/auth/token/', {
username: login.username, password: login.password,
})
localStorage.setItem('token', data.access)
localStorage.setItem('username', login.username)
localStorage.setItem('tenant', login.tenant.trim() || 'default')
localStorage.removeItem('readonly')
token.value = data.access
username.value = login.username
tenant.value = login.tenant.trim() || 'default'
await loadRefs()
await loadDebts()
} catch (e) {
ElMessage.error('登录失败:用户名/密码/租户不正确')
} finally {
loading.value = false
}
}
function logout() {
localStorage.removeItem('token')
token.value = ''
order.value = []
}
function lineOf(p) {
if (!lineState[p.id]) {
lineState[p.id] = reactive({
qty: 0, unitId: null, unitName: '', units: [], price: 0, source: '', taxRate: 0,
})
void initLine(p)
}
return lineState[p.id]
}
async function initLine(p) {
const row = lineState[p.id]
try {
const info = await api.get(`/catalog/products/${p.id}/units/`)
row.units = info.units || []
const base = row.units.find((u) => u.is_base) || row.units[0]
row.unitId = base?.unit_id ?? null
row.unitName = base?.unit_name || ''
row.price = Number(base?.price ?? info.sale_price ?? 0)
row.taxRate = Number(info.tax_rate || 0)
} catch (e) {
row.price = Number(p.sale_price || 0)
}
}
function onUnitChange(p) {
const row = lineOf(p)
const u = (row.units || []).find((x) => x.unit_id === row.unitId) || row.units[0]
row.unitName = u?.unit_name || ''
// 切单位后按同口径重算单价:已有取价用取价换算,否则用基本售价换算
if (row.baseQuotePrice != null) {
row.price = Number(row.baseQuotePrice) * unitRate({ source_unit: row.unitId, units: row.units })
} else {
row.price = Number(u?.price ?? row.price ?? 0)
}
if (form.customer) void fetchQuote(p)
}
async function fetchQuote(p) {
if (!form.customer) return
const row = lineOf(p)
try {
const data = await api.get('/partner/price-quote/', {
params: { customer_id: form.customer, product_ids: p.id },
})
const q = (data.results || [])[0]
if (!q) return
row.source = q.source || ''
row.baseQuotePrice = Number(q.price)
row.price = Number(q.price) * unitRate({ source_unit: row.unitId, units: row.units })
} catch (e) {
/* 取价失败不阻断开单 */
}
}
async function onCustomerChange() {
credit.value = {}
if (!form.customer) return
try {
credit.value = await api.get(`/partner/credit-usage/${form.customer}/`)
} catch (e) {
/* 无额度信息不阻断 */
}
for (const p of visibleProducts.value) {
if (lineState[p.id]) await fetchQuote(p)
}
}
function filterProducts() {
const kw = search.value.trim().toLowerCase()
if (!kw) { visibleProducts.value = products.value; return }
visibleProducts.value = products.value.filter(
(p) => (p.name || '').toLowerCase().includes(kw) || (p.code || '').toLowerCase().includes(kw),
)
}
function addLine(p) {
const row = lineOf(p)
if (!row.qty || row.qty <= 0) { ElMessage.warning('请填写数量'); return }
order.value.push({
product: p.id, name: p.name, qty: row.qty,
unitId: row.unitId, unitName: row.unitName, units: row.units,
price: row.price, taxRate: row.taxRate,
})
row.qty = 0
ElMessage.success(`已加入:${p.name}`)
}
function lineTax(p) {
const row = lineOf(p)
const amt = Number(row.qty || 0) * Number(row.price || 0)
const rate = Number(row.taxRate || 0)
if (!rate || amt <= 0) return '0.00'
return (amt - amt / (1 + rate)).toFixed(2)
}
function rowAmount(r) {
return lineAmount({ quantity: r.qty, unit_price: r.price })
}
const linesTotal = computed(() => order.value.reduce((s, r) => s + rowAmount(r), 0))
const taxTotal = computed(() =>
order.value.reduce((s, r) => {
const amt = rowAmount(r)
const rate = Number(r.taxRate || 0)
return s + (rate > 0 && amt > 0 ? amt - amt / (1 + rate) : 0)
}, 0),
)
const roundOffAmount = computed(() => {
const rt = Number(form.round_to || 0)
if (!rt) return 0
const total = linesTotal.value
return Number((total - Math.floor(total / rt) * rt).toFixed(4))
})
const netTotal = computed(() => linesTotal.value - roundOffAmount.value)
const creditPercent = computed(() => {
const limit = Number(credit.value.limit || 0)
if (!limit) return 0
return Math.min(100, Math.round(Number(credit.value.outstanding || 0) / limit * 100))
})
const creditStatus = computed(() => {
if (creditPercent.value >= 100) return 'exception'
if (creditPercent.value >= 80) return 'warning'
return 'success'
})
async function submitBill() {
if (!form.customer) { ElMessage.warning('请选择客户'); return }
if (!form.warehouse) { ElMessage.warning('请选择仓库'); return }
const badQty = order.value.filter((r) => !validateQuantity({ quantity: r.qty }))
if (badQty.length) { ElMessage.error('有明细数量不合法(需 ≥0.001 且 ≤10 亿)'); return }
const badPrice = order.value.filter((r) => !validatePrice({ unit_price: r.price }))
if (badPrice.length) { ElMessage.error('有明细单价不合法(必须大于 0 且不超过 10 亿)'); return }
const payload = {
customer: form.customer,
warehouse: form.warehouse,
round_to: form.round_to || undefined,
lines: order.value.map((r) => ({
product: r.product, quantity: r.qty, unit_price: r.price,
source_unit: r.unitId || undefined,
})),
}
submitting.value = true
try {
const bill = await api.post('/sales/bills/create-bill/', payload)
try {
await api.post(`/sales/bills/${bill.id}/confirm/`, { force: false })
ElMessage.success(`${bill.bill_no || '销售单'} 已过账`)
} catch (err) {
const d = err.response?.data || {}
if (err.response?.status === 402 || d.code === 'credit_limit_exceeded') {
try {
await ElMessageBox.confirm(
`${d.detail}。是否强制过账(将记录超限预警)?`,
'信用额度超限', {
type: 'error', confirmButtonText: '强制过账', cancelButtonText: '取消',
},
)
} catch (e) { await loadDebts(); return }
await api.post(`/sales/bills/${bill.id}/confirm/`, { force: true })
ElMessage.success(`${bill.bill_no || '销售单'} 已强制过账`)
} else {
ElMessage.warning(`草稿已保存(${bill.bill_no || ''}),过账失败:${d.detail || '库存不足/其他'}`)
}
}
order.value = []
form.round_to = ''
await loadDebts()
} catch (e) {
/* 拦截器已提示 */
} finally {
submitting.value = false
}
}
// ---- 查欠款 ----
const debts = ref([])
const debtsLoading = ref(false)
const debtCustomer = ref(null)
const debtStatusMap = { open: '未收', partial: '部分已收', paid: '已结清', cancelled: '已取消' }
const debtStatusText = (s) => debtStatusMap[s] || s
const debtTotal = computed(() =>
debts.value.filter((d) => d.status !== 'paid')
.reduce((s, d) => s + Number(d.balance || 0), 0),
)
async function loadDebts() {
debtsLoading.value = true
try {
const params = { page_size: 50 }
if (debtCustomer.value) params.customer = debtCustomer.value
// 后端 Receivables 按 search/status 过滤,无 customer 精确过滤时前端按客户筛
const data = await api.get('/finance/receivables/', { params })
let rows = data.results || []
// 只要未结清
rows = rows.filter((r) => r.status === 'open' || r.status === 'partial')
if (debtCustomer.value) rows = rows.filter((r) => r.customer === debtCustomer.value)
debts.value = rows
} catch (e) {
debts.value = []
} finally {
debtsLoading.value = false
}
}
// ---- 收款登记 ----
const receiptRef = ref(null)
const receiptSaving = ref(false)
const receipt = reactive({
customer: null, bill_no: '', bill_date: '', amount: null, method: '', remark: '',
})
const receiptRules = {
customer: [{ required: true, message: '请选择客户', trigger: 'change' }],
bill_no: [{ required: true, message: '请填写收款单号', trigger: 'blur' }],
bill_date: [{ required: true, message: '请选择收款日期', trigger: 'change' }],
amount: [
{ required: true, message: '请输入收款金额', trigger: 'blur' },
{ validator: (r, v, cb) => (Number(v) > 0 ? cb() : cb(new Error('收款金额必须大于 0'))), trigger: 'blur' },
],
}
function today() {
return new Date().toISOString().slice(0, 10)
}
async function saveReceipt() {
const ok = await receiptRef.value?.validate().catch(() => false)
if (!ok) return
receiptSaving.value = true
try {
const payload = {
customer: receipt.customer,
bill_no: receipt.bill_no.trim(),
bill_date: receipt.bill_date,
amount: String(receipt.amount),
method: receipt.method || '',
remark: receipt.remark || '',
}
await api.post('/finance/receipts/', payload)
ElMessage.success('收款单已登记')
receipt.bill_no = ''
receipt.amount = null
receipt.remark = ''
await loadDebts()
} catch (e) {
const d = e.response?.data || {}
const first = d.customer || d.bill_no || d.bill_date || d.amount || d.detail
if (first) ElMessage.error(`登记失败:${Array.isArray(first) ? first[0] : first}`)
} finally {
receiptSaving.value = false
}
}
async function loadRefs() {
const [c, w, p] = await Promise.all([
api.get('/partner/customers/', { params: { page_size: 200 } }),
api.get('/inventory/warehouses/', { params: { page_size: 200 } }),
api.get('/catalog/products/', { params: { page_size: 200 } }),
])
customers.value = c.results || []
warehouses.value = w.results || []
products.value = p.results || []
visibleProducts.value = products.value
if (!form.warehouse && warehouses.value.length) {
form.warehouse = warehouses.value[0].id
}
if (!receipt.bill_date) receipt.bill_date = today()
}
onMounted(async () => {
if (token.value) {
try {
await loadRefs()
await loadDebts()
} catch (e) {
// token 失效由拦截器踢回登录
}
} else if (!receipt.bill_date) {
receipt.bill_date = today()
}
})
</script>
<style scoped>
.sm-wrap { max-width: 560px; margin: 0 auto; padding: 12px; }
.sm-login { padding-top: 40px; }
.sm-login h2 { margin: 0 0 6px; font-size: 22px; }
.sm-sub { color: #909399; font-size: 13px; margin: 0 0 18px; }
.sm-head {
display: flex; align-items: center; justify-content: space-between;
padding-bottom: 10px; border-bottom: 1px solid #ebeef5; margin-bottom: 10px;
}
.sm-shop { font-size: 17px; font-weight: 600; }
.sm-search { margin: 10px 0; }
.sm-list { margin-top: 10px; }
.sm-item, .sm-debt {
display: flex; align-items: center; justify-content: space-between;
gap: 10px; padding: 12px 10px; border: 1px solid #ebeef5;
border-radius: 8px; margin-bottom: 10px; background: #fff;
}
.sm-item-main { flex: 1; min-width: 0; }
.sm-name { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
.sm-meta { color: #909399; font-size: 12px; margin-bottom: 4px; }
.sm-price { color: #d4380d; font-weight: 700; font-size: 15px; }
.sm-price-unit { color: #909399; font-size: 12px; font-weight: 400; }
.sm-tax, .sm-tax-total { color: #909399; font-size: 12px; }
.sm-item-ops { display: flex; align-items: center; gap: 6px; }
.sm-order { margin-top: 12px; border-top: 1px solid #ebeef5; padding-top: 10px; }
.sm-order-title { font-weight: 600; margin-bottom: 6px; }
.sm-order-row {
display: flex; align-items: center; justify-content: space-between;
gap: 8px; padding: 6px 0; font-size: 13px;
}
.sm-order-name { flex: 1; min-width: 0; }
.sm-totals {
display: flex; gap: 10px; justify-content: flex-end; align-items: center;
padding: 8px 0; font-size: 13px; flex-wrap: wrap;
}
.sm-net { font-size: 16px; color: #d4380d; }
.sm-round { color: #e6a23c; }
.sm-debt-sum { padding: 8px 2px; font-size: 14px; }
.sm-debt-sum b { color: #d4380d; font-size: 17px; }
.credit-bar {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; background: #fafbfc;
border: 1px solid #e4e7ed; border-radius: 6px; margin-bottom: 6px;
}
.credit-label { font-size: 12px; color: #606266; white-space: nowrap; }
@media (max-width: 480px) {
.sm-item { flex-direction: column; align-items: stretch; }
.sm-item-ops { justify-content: flex-end; }
}
</style>
+2 -1
View File
@@ -5,6 +5,7 @@ const routes = [
{ path: '/login', name: 'login', component: () => import('../pages/Login.vue') },
{ path: '/register', name: 'register', component: () => import('../pages/Register.vue') },
{ path: '/storefront', name: 'storefront', component: () => import('../pages/Storefront.vue'), meta: { title: '订货商城' } },
{ path: '/salesman', name: 'salesman', component: () => import('../pages/Salesman.vue'), meta: { title: '业务员开单' } },
{ path: '/storefront-admin', name: 'storefrontAdmin', component: () => import('../pages/StorefrontAdmin.vue'), meta: { title: '商城订单' } },
{
path: '/',
@@ -40,7 +41,7 @@ const routes = [
const router = createRouter({ history: createWebHashHistory(), routes })
const PUBLIC_ROUTES = new Set(['login', 'register', 'storefront'])
const PUBLIC_ROUTES = new Set(['login', 'register', 'storefront', 'salesman'])
router.beforeEach((to) => {
const token = localStorage.getItem('token')