诚实性修复: API限流Retry-After(wait=60s)+断言 / purge定时接线(04:00 cron+actor+测试) / Channel表单校验(ref+rules+validate) / README PG数字513

This commit is contained in:
seagull
2026-09-12 02:20:50 +08:00
parent b3f3095d53
commit 67540e8d30
40 changed files with 1805 additions and 31 deletions
+17 -5
View File
@@ -44,8 +44,8 @@
</el-table>
<el-dialog v-model="dialogVisible" title="接入新店铺" width="480px">
<el-form :model="form" label-width="100px">
<el-form-item label="平台">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="平台" prop="platform">
<el-select v-model="form.platform" style="width: 100%">
<el-option label="抖音电商 / 抖店" value="douyin" />
<el-option label="拼多多" value="pinduoduo" />
@@ -54,9 +54,9 @@
<el-option label="自定义开放接口" value="custom" />
</el-select>
</el-form-item>
<el-form-item label="店铺ID"><el-input v-model="form.shop_id" /></el-form-item>
<el-form-item label="店铺名称"><el-input v-model="form.shop_name" /></el-form-item>
<el-form-item label="App Key"><el-input v-model="form.app_key" placeholder="留空则使用模拟数据" /></el-form-item>
<el-form-item label="店铺ID" prop="shop_id"><el-input v-model="form.shop_id" /></el-form-item>
<el-form-item label="店铺名称" prop="shop_name"><el-input v-model="form.shop_name" /></el-form-item>
<el-form-item label="App Key" prop="app_key"><el-input v-model="form.app_key" placeholder="留空则使用模拟数据" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
@@ -78,7 +78,17 @@ const ordersLoading = ref(false)
const syncingId = ref(null)
const saving = ref(false)
const dialogVisible = ref(false)
const formRef = ref(null)
const form = reactive({ platform: 'douyin', shop_id: '', shop_name: '', app_key: '' })
// P1-2:与 validate.js 同口径(平台/店铺ID 必填;App Key 留空合法=走 Mock,不拦)
const rules = {
platform: [{ required: true, message: '请选择平台', trigger: 'change' }],
shop_id: [
{ required: true, message: '请输入店铺ID', trigger: 'blur' },
{ max: 64, message: '店铺ID最多 64 字符', trigger: 'blur' },
],
shop_name: [{ required: true, message: '请输入店铺名称', trigger: 'blur' }],
}
async function load() {
loading.value = true
@@ -103,6 +113,8 @@ function openCreate() {
}
async function save() {
const ok = await formRef.value.validate().catch(() => false)
if (!ok) return
saving.value = true
try {
await api.post('/channel/accounts/', form)
+2
View File
@@ -22,6 +22,7 @@
🎬 免注册体验演示账套
</el-button>
<div class="demo-hint">演示账套为只读模式,可试用全部查询、报表与 AI 功能</div>
<div class="back-link"><router-link to="/register">没有账号?免费注册开通账套</router-link></div>
</el-form>
</el-card>
</div>
@@ -107,4 +108,5 @@ async function enterDemo() {
margin-top: 8px; text-align: center;
color: #c0c4cc; font-size: 12px;
}
.back-link { margin-top: 10px; text-align: center; }
</style>
+166
View File
@@ -8,6 +8,7 @@
<el-option label="已结清" value="paid" />
</el-select>
<el-button type="primary" @click="load">查询</el-button>
<el-button type="success" @click="openPayment">登记付款</el-button>
</div>
<el-table :data="rows" v-loading="loading" size="small" stripe>
@@ -33,6 +34,11 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" :disabled="row.status === 'paid'" @click="openAllocate(row)">核销</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@@ -43,11 +49,66 @@
v-model:current-page="page"
@current-change="load"
/>
<el-dialog v-model="paymentVisible" title="登记付款" width="480px">
<el-form ref="paymentFormRef" :model="paymentForm" :rules="paymentRules" label-width="90px">
<el-form-item label="供应商" prop="supplier">
<el-select v-model="paymentForm.supplier" filterable placeholder="选择供应商" style="width: 100%">
<el-option v-for="s in suppliers" :key="s.id" :label="`${s.code} · ${s.name}`" :value="s.id" />
</el-select>
</el-form-item>
<el-form-item label="单据编号" prop="bill_no">
<el-input v-model="paymentForm.bill_no" placeholder="留空自动生成" maxlength="64" />
</el-form-item>
<el-form-item label="付款日期" prop="bill_date">
<el-date-picker v-model="paymentForm.bill_date" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
<el-form-item label="付款金额" prop="amount">
<el-input-number v-model="paymentForm.amount" :precision="2" :min="0" style="width: 100%" />
</el-form-item>
<el-form-item label="方式" prop="method">
<el-select v-model="paymentForm.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="备注" prop="remark"><el-input v-model="paymentForm.remark" maxlength="500" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="paymentVisible = false">取消</el-button>
<el-button type="primary" :loading="paymentSaving" @click="savePayment">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="allocVisible" :title="`核销应付单 ${allocTarget?.bill_no || ''}`" width="480px">
<el-form ref="allocFormRef" :model="allocForm" :rules="allocRules" label-width="90px">
<el-form-item label="付款单" prop="payment">
<el-select v-model="allocForm.payment" filterable placeholder="选择草稿付款单" style="width: 100%">
<el-option
v-for="p in payments" :key="p.id"
:label="`${p.bill_no} · ¥${p.amount}(${p.bill_date})`"
:value="p.id"
/>
</el-select>
</el-form-item>
<el-form-item label="核销金额" prop="amount">
<el-input-number v-model="allocForm.amount" :precision="2" :min="0" style="width: 100%" />
</el-form-item>
<div v-if="allocTarget" class="hint">应付未结余额 ¥{{ allocTarget.balance }};超额核销会被后端拒绝(400)。</div>
</el-form>
<template #footer>
<el-button @click="allocVisible = false">取消</el-button>
<el-button type="primary" :loading="allocSaving" @click="saveAllocate">确认核销</el-button>
</template>
</el-dialog>
</el-card>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import api from '../api/client'
const rows = ref([])
@@ -74,10 +135,115 @@ async function load() {
}
}
// ---- 登记付款 ----
const paymentVisible = ref(false)
const paymentSaving = ref(false)
const paymentFormRef = ref(null)
const paymentForm = ref({ supplier: null, bill_no: '', bill_date: '', amount: null, method: '', remark: '' })
const paymentRules = {
supplier: [{ required: true, message: '请选择供应商', trigger: 'change' }],
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' },
],
}
const suppliers = ref([])
function today() {
return new Date().toISOString().slice(0, 10)
}
async function openPayment() {
paymentForm.value = { supplier: null, bill_no: '', bill_date: today(), amount: null, method: '', remark: '' }
if (!suppliers.value.length) {
try {
const s = await api.get('/partner/suppliers/', { params: { page_size: 200 } })
suppliers.value = s.results || []
} catch (e) { /* 拦截器已提示 */ }
}
paymentVisible.value = true
}
async function savePayment() {
const ok = await paymentFormRef.value.validate().catch(() => false)
if (!ok) return
paymentSaving.value = true
try {
const payload = {
supplier: paymentForm.value.supplier,
bill_date: paymentForm.value.bill_date,
amount: String(paymentForm.value.amount),
method: paymentForm.value.method || '',
remark: paymentForm.value.remark || '',
}
if (paymentForm.value.bill_no.trim()) payload.bill_no = paymentForm.value.bill_no.trim()
await api.post('/finance/payments/', payload)
ElMessage.success('付款单已登记')
paymentVisible.value = false
await load()
} catch (e) {
const d = e.response?.data || {}
const first = d.supplier || d.bill_no || d.bill_date || d.amount || d.detail
if (first) ElMessage.error(`登记失败:${Array.isArray(first) ? first[0] : first}`)
} finally {
paymentSaving.value = false
}
}
// ---- 核销:把一张付款单的金额核到当前应付单 ----
const allocVisible = ref(false)
const allocSaving = ref(false)
const allocFormRef = ref(null)
const allocTarget = ref(null)
const allocForm = ref({ payment: null, amount: null })
const allocRules = {
payment: [{ 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' },
],
}
const payments = ref([])
async function openAllocate(row) {
allocTarget.value = row
allocForm.value = { payment: null, amount: Number(row.balance) > 0 ? row.balance : null }
try {
const r = await api.get('/finance/payments/', {
params: { page_size: 200, supplier: row.supplier, status: 'draft' },
})
payments.value = r.results || []
} catch (e) { payments.value = []; /* 拦截器已提示 */ }
allocVisible.value = true
}
async function saveAllocate() {
const ok = await allocFormRef.value.validate().catch(() => false)
if (!ok) return
allocSaving.value = true
try {
const data = await api.post(`/finance/payments/${allocForm.value.payment}/allocate/`, {
allocations: [{ payable: allocTarget.value.id, amount: String(allocForm.value.amount) }],
})
const left = data.payment ? `,付款单剩余 ${data.payment.amount}` : ''
ElMessage.success(`核销成功${left}`)
allocVisible.value = false
await load()
} catch (e) {
const d = e.response?.data || {}
const first = d.allocations || d.detail
if (first) ElMessage.error(`核销失败:${Array.isArray(first) ? first[0] : first}`)
} finally {
allocSaving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 14px; }
.danger { color: #f56c6c; font-weight: 600; }
.hint { margin-top: 4px; color: #909399; font-size: 12px; }
</style>
+166
View File
@@ -8,6 +8,7 @@
<el-option label="已结清" value="paid" />
</el-select>
<el-button type="primary" @click="load">查询</el-button>
<el-button type="success" @click="openReceipt">登记收款</el-button>
</div>
<el-table :data="rows" v-loading="loading" size="small" stripe>
@@ -33,6 +34,11 @@
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" :disabled="row.status === 'paid'" @click="openAllocate(row)">核销</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
@@ -43,11 +49,66 @@
v-model:current-page="page"
@current-change="load"
/>
<el-dialog v-model="receiptVisible" title="登记收款" width="480px">
<el-form ref="receiptFormRef" :model="receiptForm" :rules="receiptRules" label-width="90px">
<el-form-item label="客户" prop="customer">
<el-select v-model="receiptForm.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">
<el-input v-model="receiptForm.bill_no" placeholder="留空自动生成" maxlength="64" />
</el-form-item>
<el-form-item label="收款日期" prop="bill_date">
<el-date-picker v-model="receiptForm.bill_date" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
</el-form-item>
<el-form-item label="收款金额" prop="amount">
<el-input-number v-model="receiptForm.amount" :precision="2" :min="0" style="width: 100%" />
</el-form-item>
<el-form-item label="方式" prop="method">
<el-select v-model="receiptForm.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="备注" prop="remark"><el-input v-model="receiptForm.remark" maxlength="500" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="receiptVisible = false">取消</el-button>
<el-button type="primary" :loading="receiptSaving" @click="saveReceipt">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="allocVisible" :title="`核销应收单 ${allocTarget?.bill_no || ''}`" width="480px">
<el-form ref="allocFormRef" :model="allocForm" :rules="allocRules" label-width="90px">
<el-form-item label="收款单" prop="receipt">
<el-select v-model="allocForm.receipt" filterable placeholder="选择草稿收款单" style="width: 100%">
<el-option
v-for="r in receipts" :key="r.id"
:label="`${r.bill_no} · ¥${r.amount}(${r.bill_date})`"
:value="r.id"
/>
</el-select>
</el-form-item>
<el-form-item label="核销金额" prop="amount">
<el-input-number v-model="allocForm.amount" :precision="2" :min="0" style="width: 100%" />
</el-form-item>
<div v-if="allocTarget" class="hint">应收未结余额 ¥{{ allocTarget.balance }};超额核销会被后端拒绝(400)。</div>
</el-form>
<template #footer>
<el-button @click="allocVisible = false">取消</el-button>
<el-button type="primary" :loading="allocSaving" @click="saveAllocate">确认核销</el-button>
</template>
</el-dialog>
</el-card>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import api from '../api/client'
const rows = ref([])
@@ -74,10 +135,115 @@ async function load() {
}
}
// ---- 登记收款:建收款单(后端自动处理 bill_no 唯一/租户隔离,错误由拦截器+本地透出) ----
const receiptVisible = ref(false)
const receiptSaving = ref(false)
const receiptFormRef = ref(null)
const receiptForm = ref({ customer: null, bill_no: '', bill_date: '', amount: null, method: '', remark: '' })
const receiptRules = {
customer: [{ required: true, message: '请选择客户', trigger: 'change' }],
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' },
],
}
const customers = ref([])
function today() {
return new Date().toISOString().slice(0, 10)
}
async function openReceipt() {
receiptForm.value = { customer: null, bill_no: '', bill_date: today(), amount: null, method: '', remark: '' }
if (!customers.value.length) {
try {
const c = await api.get('/partner/customers/', { params: { page_size: 200 } })
customers.value = c.results || []
} catch (e) { /* 拦截器已提示 */ }
}
receiptVisible.value = true
}
async function saveReceipt() {
const ok = await receiptFormRef.value.validate().catch(() => false)
if (!ok) return
receiptSaving.value = true
try {
const payload = {
customer: receiptForm.value.customer,
bill_date: receiptForm.value.bill_date,
amount: String(receiptForm.value.amount),
method: receiptForm.value.method || '',
remark: receiptForm.value.remark || '',
}
if (receiptForm.value.bill_no.trim()) payload.bill_no = receiptForm.value.bill_no.trim()
await api.post('/finance/receipts/', payload)
ElMessage.success('收款单已登记')
receiptVisible.value = false
await load()
} 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
}
}
// ---- 核销:把一张收款单的金额核到当前应收单(可部分核销) ----
const allocVisible = ref(false)
const allocSaving = ref(false)
const allocFormRef = ref(null)
const allocTarget = ref(null)
const allocForm = ref({ receipt: null, amount: null })
const allocRules = {
receipt: [{ 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' },
],
}
const receipts = ref([])
async function openAllocate(row) {
allocTarget.value = row
allocForm.value = { receipt: null, amount: Number(row.balance) > 0 ? row.balance : null }
try {
const r = await api.get('/finance/receipts/', {
params: { page_size: 200, customer: row.customer, status: 'draft' },
})
receipts.value = r.results || []
} catch (e) { receipts.value = []; /* 拦截器已提示 */ }
allocVisible.value = true
}
async function saveAllocate() {
const ok = await allocFormRef.value.validate().catch(() => false)
if (!ok) return
allocSaving.value = true
try {
const data = await api.post(`/finance/receipts/${allocForm.value.receipt}/allocate/`, {
allocations: [{ receivable: allocTarget.value.id, amount: String(allocForm.value.amount) }],
})
const left = data.receipt ? `,收款单剩余 ${data.receipt.amount}` : ''
ElMessage.success(`核销成功${left}`)
allocVisible.value = false
await load()
} catch (e) {
const d = e.response?.data || {}
const first = d.allocations || d.detail
if (first) ElMessage.error(`核销失败:${Array.isArray(first) ? first[0] : first}`)
} finally {
allocSaving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.toolbar { display: flex; gap: 10px; margin-bottom: 14px; }
.danger { color: #f56c6c; font-weight: 600; }
.hint { margin-top: 4px; color: #909399; font-size: 12px; }
</style>
+125
View File
@@ -0,0 +1,125 @@
<template>
<div class="login-wrap">
<el-card class="login-card">
<h2 class="title">免费注册 · 开通你的账套</h2>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" @keyup.enter="submit">
<el-form-item label="公司名称" prop="company_name">
<el-input v-model="form.company_name" placeholder="如:海鸥贸易" />
</el-form-item>
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="登录用,注册后不可改" />
</el-form-item>
<el-form-item label="手机号(可选)" prop="phone">
<el-input v-model="form.phone" placeholder="13800000000" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" type="password" show-password placeholder="至少 8 位" />
</el-form-item>
<el-form-item label="确认密码" prop="confirm">
<el-input v-model="form.confirm" type="password" show-password placeholder="再输一次" />
</el-form-item>
<el-button type="primary" style="width: 100%" :loading="loading" @click="submit">
注 册 并 进 入
</el-button>
<div class="back-link"><router-link to="/login">已有账号?去登录</router-link></div>
<div class="demo-hint">注册即开通免费版账套,成为账套所有者</div>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import axios from 'axios'
const router = useRouter()
const loading = ref(false)
const formRef = ref(null)
const form = reactive({
company_name: '',
username: '',
phone: '',
password: '',
confirm: '',
})
const rules = {
company_name: [
{ required: true, message: '请输入公司名称', trigger: 'blur' },
{ min: 2, message: '公司名称至少 2 个字符', trigger: 'blur' },
],
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, message: '用户名至少 2 个字符', trigger: 'blur' },
],
phone: [
{
validator: (rule, value, cb) => {
if (!value) return cb()
return /^1\d{10}$/.test(value.trim())
? cb()
: cb(new Error('手机号格式不正确'))
},
trigger: 'blur',
},
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 8, message: '密码至少 8 位', trigger: 'blur' },
],
confirm: [
{ required: true, message: '请再输一次密码', trigger: 'blur' },
{
validator: (rule, value, cb) =>
value === form.password ? cb() : cb(new Error('两次输入的密码不一致')),
trigger: 'blur',
},
],
}
async function submit() {
const ok = await formRef.value.validate().catch(() => false)
if (!ok) return
loading.value = true
try {
const { data } = await axios.post('/api/v1/auth/register/', {
username: form.username.trim(),
password: form.password,
company_name: form.company_name.trim(),
phone: form.phone.trim(),
})
localStorage.setItem('token', data.access)
localStorage.setItem('refresh', data.refresh)
localStorage.setItem('username', data.username)
localStorage.setItem('tenant', data.tenant)
localStorage.removeItem('readonly')
localStorage.removeItem('sf_token')
localStorage.removeItem('sf_customer')
localStorage.removeItem('sf_shop')
ElMessage.success(`注册成功,已为「${data.tenant}」开通账套`)
router.push('/dashboard')
} catch (e) {
const d = e.response?.data || {}
const first = d.username || d.password || d.company_name || d.phone || d.detail
ElMessage.error(`注册失败:${Array.isArray(first) ? first[0] : first || '请检查输入'}`)
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-wrap {
height: 100vh; display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #001529 0%, #16325c 100%);
}
.login-card { width: 400px; padding: 8px 12px; }
.title { text-align: center; margin: 4px 0 20px; color: #303133; font-size: 20px; }
.back-link { margin-top: 12px; text-align: center; }
.demo-hint {
margin-top: 8px; text-align: center;
color: #c0c4cc; font-size: 12px;
}
</style>
+15 -5
View File
@@ -60,11 +60,11 @@
<!-- 手工制单 -->
<el-dialog v-model="dialogVisible" title="手工制单(借贷必须平衡)" width="680px">
<el-form label-width="90px">
<el-form-item label="凭证日期">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="凭证日期" prop="voucher_date">
<el-date-picker v-model="form.voucher_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="摘要"><el-input v-model="form.summary" /></el-form-item>
<el-form-item label="摘要" prop="summary"><el-input v-model="form.summary" /></el-form-item>
<el-form-item label="过账方式">
<el-switch v-model="form.auto_post" active-text="创建后立即过账" inactive-text="保存为草稿" />
</el-form-item>
@@ -107,6 +107,13 @@
import { onMounted, ref, reactive, computed } from 'vue'
import { ElMessage } from 'element-plus'
import api from '../api/client'
import { voucherEntryErrors } from '../utils/validate'
const formRef = ref(null)
const rules = {
voucher_date: [{ required: true, message: '请选择凭证日期', trigger: 'change' }],
summary: [{ required: true, message: '请输入摘要', trigger: 'blur' }],
}
const rows = ref([])
const count = ref(0)
@@ -154,8 +161,11 @@ function addEntry(side) {
}
async function save() {
if (!balanced.value) {
ElMessage.error('借贷不平衡,无法保存')
const ok = await formRef.value.validate().catch(() => false)
if (!ok) return
const entryErrors = voucherEntryErrors(form.entries)
if (entryErrors.length) {
ElMessage.error(entryErrors[0])
return
}
const payload = {
+1
View File
@@ -3,6 +3,7 @@ import MainLayout from '../layouts/MainLayout.vue'
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: '/storefront-admin', name: 'storefrontAdmin', component: () => import('../pages/StorefrontAdmin.vue'), meta: { title: '商城订单' } },
{
+64
View File
@@ -0,0 +1,64 @@
// P1-2 · 前端纯函数校验(与 transaction.js 同风格:无框架依赖 + node --test 单测)。
//
// 注意(迭代 2 教训):Element Plus `el-input-number` 的 `:min` 是自动纠正
// 不是校验 —— 非法值会被静默改掉。所以 `:min` 要允许非法值如实呈现,
// 由这里的显式校验函数拦截。
export function required(value) {
if (value === null || value === undefined) return false
return String(value).trim().length > 0
}
export function positiveAmount(value) {
const n = Number(value)
return Number.isFinite(n) && n > 0
}
export function nonEmptyArray(value) {
return Array.isArray(value) && value.length > 0
}
export function shopId(value) {
return required(value) && String(value).trim().length <= 64
}
export function voucherBalanced(entries) {
if (!nonEmptyArray(entries)) return false
let debit = 0
let credit = 0
for (const e of entries) {
const d = Number(e?.debit || 0)
const c = Number(e?.credit || 0)
if (!Number.isFinite(d) || !Number.isFinite(c) || d < 0 || c < 0) return false
if (d > 0 && c > 0) return false // 同一行借贷不能同时有数
const code = String(e?.account_code || '').trim()
if (!code) return false
debit += d
credit += c
}
return debit > 0 && debit === credit
}
export function voucherEntryErrors(entries) {
const errors = []
if (!nonEmptyArray(entries)) {
errors.push('至少需要一条分录')
return errors
}
entries.forEach((e, i) => {
const n = i + 1
if (!String(e?.account_code || '').trim()) errors.push(`第 ${n} 行:科目编码必填`)
const d = Number(e?.debit || 0)
const c = Number(e?.credit || 0)
if (d > 0 && c > 0) errors.push(`第 ${n} 行:借方贷方不能同时填写`)
if (d < 0 || c < 0) errors.push(`第 ${n} 行:金额不能为负`)
if (d === 0 && c === 0) errors.push(`第 ${n} 行:借方贷方至少填一边`)
})
if (errors.length === 0) {
const debit = entries.reduce((s, e) => s + Number(e?.debit || 0), 0)
const credit = entries.reduce((s, e) => s + Number(e?.credit || 0), 0)
if (debit <= 0) errors.push('借方合计必须大于 0')
else if (debit !== credit) errors.push(`借贷不平衡:借 ${debit} ≠ 贷 ${credit}`)
}
return errors
}