feat(T+): 完成数据贯通与自动接入闭环
打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。 新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。 补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
This commit is contained in:
1 parent
3efe72c344
commit
1e53792ad6
151 files changed
+8842
-401
No files matched your search
@@ -0,0 +1,12 @@
|
||||
# 从仓库根目录构建,生产前端通过同源 /api 访问后端。
|
||||
FROM node:22-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
RUN npm install --global pnpm@10.11.0
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY frontend/ ./
|
||||
RUN pnpm build
|
||||
|
||||
FROM nginx:1.28-alpine
|
||||
COPY docker/nginx.prod.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
@@ -0,0 +1,62 @@
|
||||
/** 使用隔离模拟接口验证真实页面的批量分类交互。 */
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
|
||||
const base = process.env.OPS_UI_URL || 'http://127.0.0.1:5173'
|
||||
const calls = []
|
||||
const state = { customers: null, products: null, owner: null }
|
||||
const browser = await chromium.launch({ headless: true, channel: 'msedge' })
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } })
|
||||
const errors = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
await page.addInitScript(() => localStorage.setItem('ys_token', 'synthetic-batch-ui'))
|
||||
await page.route(url => url.pathname.startsWith('/api/'), async route => {
|
||||
const req = route.request(), url = new URL(req.url()), path = url.pathname.replace('/api/v1', '')
|
||||
let data = []
|
||||
if (path === '/auth/me') data = { id: 1, username: 'ui-test', roles: ['admin'], permissions: ['base:customers:view', 'base:customers:update', 'base:customers:ownership', 'base:products:view', 'base:products:update'] }
|
||||
else if (path.endsWith('/batch-ownership')) {
|
||||
const payload = req.postDataJSON(); calls.push({kind: 'ownership', ...payload}); state.owner = payload.new_owner_personnel_id; data = {updated: payload.ids.length}
|
||||
}
|
||||
else if (path.endsWith('/batch-category')) {
|
||||
const payload = req.postDataJSON(), kind = path.includes('customers') ? 'customers' : 'products'
|
||||
calls.push({kind, ...payload}); state[kind] = payload.category; data = { updated: payload.ids.length }
|
||||
} else if (path === '/base/customers') {
|
||||
data = {list: [{id: 11, company_name: '测试待分类客户', owner_name: '', customer_type: state.customers, owner_personnel_id: state.owner, owner_personnel_name: state.owner ? '测试归属人' : null, rating_score: null}], total: 1, pageNum: 1, pageSize: 20}
|
||||
} else if (path === '/base/products') {
|
||||
data = {list: [{id: 12, product_name: '测试待分类产品', spec_model: '', unit: '件', product_category: state.products, status: 1, stock_quantity: null, sale_price: null}], total: 1, pageNum: 1, pageSize: 20}
|
||||
} else if (path.includes('/meta/')) data = url.searchParams.get('resource') === 'personnel' ? [{id: 21, personnel_name: '测试归属人', status: 1}] : []
|
||||
await route.fulfill({ json: { code: 200, message: 'success', data } })
|
||||
})
|
||||
for (const [kind, label, category, id] of [['customers', '测试待分类客户', '企业户', 11], ['products', '测试待分类产品', '市政水利', 12]]) {
|
||||
await page.goto(`${base}/base/${kind}`)
|
||||
const row = page.getByRole('row').filter({hasText: label})
|
||||
await row.waitFor()
|
||||
await row.locator('label.el-checkbox').click()
|
||||
await page.locator('.toolbar .el-select').first().click()
|
||||
await page.getByRole('option', { name: category, exact: true }).click()
|
||||
await page.getByRole('button', {name: '批量补充分类(1)'}).click()
|
||||
await page.getByRole('dialog').getByRole('button', {name: '确定'}).click()
|
||||
await row.getByText(category, {exact: true}).waitFor()
|
||||
assert.equal(calls.at(-1).kind, kind)
|
||||
assert.deepEqual(calls.at(-1).ids, [id])
|
||||
}
|
||||
await page.goto(`${base}/base/customers`)
|
||||
const customer = page.getByRole('row').filter({hasText: '测试待分类客户'})
|
||||
await customer.waitFor()
|
||||
await customer.locator('label.el-checkbox').click()
|
||||
await page.locator('.toolbar .el-select').nth(1).click()
|
||||
await page.getByRole('option', {name: '测试归属人', exact: true}).click()
|
||||
await page.getByRole('button', {name: '批量补充归属(1)'}).click()
|
||||
await page.getByRole('dialog').getByRole('button', {name: '确定'}).click()
|
||||
await customer.getByText('测试归属人', {exact: true}).waitFor()
|
||||
assert.equal(calls.at(-1).kind, 'ownership')
|
||||
assert.deepEqual(calls.at(-1).ids, [11])
|
||||
assert.equal(calls.at(-1).new_owner_personnel_id, 21)
|
||||
assert.deepEqual(errors, [])
|
||||
process.stdout.write('客户和产品批量分类及客户批量归属页面交互通过(模拟接口)。\n')
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/** 接入日期草稿与设置刷新的浏览器回归,接口全部隔离。 */
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { createServer } from 'vite'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
|
||||
const root = fileURLToPath(new URL('../', import.meta.url))
|
||||
const id = 'virtual:business-sync-date'
|
||||
const harness = `import {createApp,h} from 'vue';import {createPinia} from 'pinia';import ElementPlus from 'element-plus';import 'element-plus/dist/index.css';import Panel from '/src/views/system/tplus/BusinessSyncPanel.vue';import {useUserStore} from '/src/store/user.ts';const pinia=createPinia();createApp({setup(){useUserStore().userInfo={permissions:['tplus:view','tplus:import']};return()=>h(Panel)}}).use(pinia).use(ElementPlus).mount('#app')`
|
||||
const server = await createServer({ configFile: false, root,
|
||||
resolve: { alias: { '@': path.join(root, 'src') } },
|
||||
define: { 'import.meta.env.VITE_API_BASE_URL': JSON.stringify('/api') },
|
||||
cacheDir: path.resolve(root, '../.ai/business-sync-date-browser/vite-cache'),
|
||||
plugins: [vue(), { name: 'business-sync-date-proof', resolveId: value => value === id ? '\0' + id : undefined,
|
||||
load: value => value === '\0' + id ? harness : undefined,
|
||||
configureServer(instance) { instance.middlewares.use('/__business-sync-date', async (_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
response.end(await instance.transformIndexHtml('/__business-sync-date', '<!doctype html><html lang="zh-CN"><meta charset="UTF-8"><div id="app"></div><script type="module" src="/@id/__x00__virtual:business-sync-date"></script></html>'))
|
||||
}) },
|
||||
}], server: { host: '127.0.0.1', port: 0 }, optimizeDeps: { include: ['vue', 'element-plus', 'pinia'] } })
|
||||
let browser
|
||||
let settings = { enabled: false, start_date: '2026-03-01', version: 1, editable: true, last_error: null }
|
||||
try {
|
||||
await server.listen()
|
||||
browser = await chromium.launch({ headless: true, channel: 'msedge' })
|
||||
const page = await browser.newPage()
|
||||
const errors = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
await page.route(url => url.pathname.startsWith('/api/'), async route => {
|
||||
const pathname = new URL(route.request().url()).pathname
|
||||
const data = pathname.endsWith('/business-sync') ? settings : pathname.endsWith('/work-runs') ? { list: [], total: 0 } : null
|
||||
assert.ok(data, '未预期接口:' + pathname)
|
||||
await route.fulfill({ json: { code: 200, message: 'success', data } })
|
||||
})
|
||||
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__business-sync-date`)
|
||||
const input = page.locator('.el-date-editor input')
|
||||
await input.waitFor()
|
||||
await page.getByText('未开启', { exact: true }).waitFor()
|
||||
await input.fill('2026-04-01')
|
||||
await input.press('Tab')
|
||||
await Promise.all([
|
||||
page.waitForResponse(response => new URL(response.url()).pathname.endsWith('/work-runs')),
|
||||
page.getByRole('button', { name: '刷新', exact: true }).click(),
|
||||
])
|
||||
assert.equal(await input.inputValue(), '2026-04-01', '刷新应保留正在填写的日期')
|
||||
settings = { ...settings, enabled: true, start_date: '2026-05-01', version: 2 }
|
||||
await Promise.all([
|
||||
page.waitForResponse(response => new URL(response.url()).pathname.endsWith('/work-runs')),
|
||||
page.getByRole('button', { name: '刷新', exact: true }).click(),
|
||||
])
|
||||
await page.getByText('已开启', { exact: true }).waitFor()
|
||||
assert.equal(await input.inputValue(), '2026-05-01', '远程开启后应显示生效日期')
|
||||
assert.equal(await input.isDisabled(), true)
|
||||
assert.deepEqual(errors, [])
|
||||
console.log('接入日期草稿、刷新与远程启用验证通过')
|
||||
} finally {
|
||||
if (browser) await browser.close()
|
||||
await server.close()
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/** 隔离渲染真实更正单组件,合成接口仅用于交互与布局验证。 */
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { createServer } from 'vite'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
|
||||
const root = fileURLToPath(new URL('../', import.meta.url))
|
||||
const output = path.resolve(root, '../.ai/correction-pairs-browser')
|
||||
fs.mkdirSync(output, { recursive: true })
|
||||
const id = 'virtual:correction-pairs'
|
||||
const harness = `
|
||||
import {createApp,h,ref} from 'vue'
|
||||
import {createPinia} from 'pinia'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import '/src/assets/styles/index.css'
|
||||
import Dialog from '/src/views/system/tplus/CorrectionPairDialog.vue'
|
||||
import {useUserStore} from '/src/store/user.ts'
|
||||
const pinia=createPinia()
|
||||
createApp({setup(){const open=ref(false);const user=useUserStore();user.userInfo={permissions:['tplus:facts']};return ()=>h('main',[
|
||||
h('button',{onClick:()=>open.value=true},'打开更正处理'),
|
||||
h('button',{onClick:()=>user.userInfo={permissions:[]}},'切换只读'),
|
||||
h(Dialog,{modelValue:open.value,'onUpdate:modelValue':v=>open.value=v})])}}).use(pinia).use(ElementPlus).mount('#app')`
|
||||
const server = await createServer({ configFile: false, root,
|
||||
resolve: { alias: { '@': path.join(root, 'src') } },
|
||||
define: { 'import.meta.env.VITE_API_BASE_URL': JSON.stringify('/api') },
|
||||
cacheDir: path.join(output, 'vite-cache'),
|
||||
plugins: [vue(), { name: 'correction-pair-proof', resolveId: value => value === id ? '\0' + id : undefined,
|
||||
load: value => value === '\0' + id ? harness : undefined,
|
||||
configureServer(instance) { instance.middlewares.use('/__correction-proof', async (_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
response.end(await instance.transformIndexHtml('/__correction-proof', '<!doctype html><html lang="zh-CN"><meta charset="UTF-8"><div id="app"></div><script type="module" src="/@id/__x00__virtual:correction-pairs"></script></html>'))
|
||||
}) },
|
||||
}], server: { host: '127.0.0.1', port: 0 }, optimizeDeps: { include: ['vue', 'element-plus', 'pinia'] } })
|
||||
let browser
|
||||
const errors = [], saves = [], checks = []
|
||||
try {
|
||||
await server.listen()
|
||||
browser = await chromium.launch({ headless: true, channel: 'msedge' })
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, reducedMotion: 'reduce' })
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
await page.route(url => url.pathname.startsWith('/api/'), async route => {
|
||||
const request = route.request(), url = new URL(request.url())
|
||||
let data
|
||||
if (url.pathname.endsWith('/correction-pairs')) { saves.push(request.postDataJSON()); data = { pair_key: 'synthetic' } }
|
||||
else if (url.pathname.endsWith('/facts')) data = { list: [{ fact_key: 'original', data: { customer_id: '8' } }], total: 1 }
|
||||
else if (url.pathname.endsWith('/source-records')) data = { list: url.searchParams.get('entity') === 'receipts'
|
||||
? [{ source_id: '30', source_no: 'B-CASH', date: '2026-03-10' }]
|
||||
: [{ source_id: '30', source_no: 'A-NEG', date: '2026-03-10' }, { source_id: '40', source_no: 'B-POS', date: '2026-03-10' }], total: 2 }
|
||||
else throw new Error('未预期接口:' + url.pathname)
|
||||
return route.fulfill({ json: { code: 200, message: 'success', data } })
|
||||
})
|
||||
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__correction-proof`)
|
||||
await page.getByRole('button', { name: '打开更正处理' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '转客户更正单处理' })
|
||||
await dialog.waitFor()
|
||||
await dialog.getByRole('button', { name: '确认成对处理' }).click()
|
||||
assert.equal(saves.length, 0)
|
||||
checks.push('必填资料不完整时阻止提交')
|
||||
for (const width of [1440, 768, 390]) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await page.screenshot({ path: path.join(output, `pair-${width}.png`), fullPage: true })
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
|
||||
}
|
||||
checks.push('1440、768、390 宽度无页面横向溢出')
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
for (const [label, option] of [['负数更正单', 'A-NEG'], ['正数更正单', 'B-POS'], ['原业务平账组', 'original'], ['实际回款', 'B-CASH']]) {
|
||||
await dialog.getByRole('combobox', { name: label, exact: true }).locator('xpath=ancestor::div[contains(@class,"el-select__wrapper")][1]').click()
|
||||
await page.getByRole('option').filter({ hasText: option }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
await dialog.getByRole('textbox', { name: '更正处理依据' }).fill('合成转客户例单已核对')
|
||||
await dialog.getByRole('button', { name: '确认成对处理' }).click()
|
||||
await dialog.waitFor({ state: 'hidden' })
|
||||
assert.deepEqual(saves[0], { negative_shipment_id: '30', positive_shipment_id: '40', receipt_ids: ['30'], target_group_key: 'original', evidence: '合成转客户例单已核对' })
|
||||
checks.push('成对选单、同 ID 不同实体、实际回款和原业务完整提交')
|
||||
await page.getByRole('button', { name: '切换只读' }).click()
|
||||
await page.getByRole('button', { name: '打开更正处理' }).click()
|
||||
await dialog.waitFor()
|
||||
assert.equal(await dialog.getByRole('button', { name: '确认成对处理' }).isDisabled(), true)
|
||||
checks.push('无事实维护权限时禁用提交')
|
||||
assert.deepEqual(errors, [])
|
||||
fs.writeFileSync(path.join(output, 'result.json'), JSON.stringify({ checks, errors }, null, 2))
|
||||
console.log(JSON.stringify({ checks, errors }))
|
||||
} finally { await browser?.close(); await server.close() }
|
||||
@@ -0,0 +1,92 @@
|
||||
/** 人工关系页面使用模拟接口验证交互,不修改业务库。 */
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { createServer } from 'vite'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
|
||||
const root = fileURLToPath(new URL('../', import.meta.url))
|
||||
const id = 'virtual:sales-relations-proof'
|
||||
const harness = `import {createApp,h} from 'vue';import {createPinia,setActivePinia} from 'pinia';import ElementPlus from 'element-plus';import 'element-plus/dist/index.css';import Review from '/src/views/business/businesses/SalesRelationReview.vue';import {useUserStore} from '/src/store/user';const pinia=createPinia();setActivePinia(pinia);useUserStore().userInfo={permissions:location.search?[]:['business:businesses:update','payroll:prepare']};createApp({render:()=>h(Review,{businessId:12})}).use(pinia).use(ElementPlus).mount('#app')`
|
||||
const server = await createServer({ configFile: false, root,
|
||||
resolve: { alias: { '@': path.join(root, 'src') } },
|
||||
define: { 'import.meta.env.VITE_API_BASE_URL': JSON.stringify('/api') },
|
||||
plugins: [vue(), { name: 'sales-relations-proof', resolveId: value => value === id ? '\0' + id : undefined,
|
||||
load: value => value === '\0' + id ? harness : undefined,
|
||||
configureServer(instance) { instance.middlewares.use('/__relations-proof', async (_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
response.end(await instance.transformIndexHtml('/__relations-proof', '<!doctype html><html lang="zh-CN"><meta charset="UTF-8"><div id="app"></div><script type="module" src="/@id/__x00__virtual:sales-relations-proof"></script></html>'))
|
||||
}) },
|
||||
}], server: { host: '127.0.0.1', port: 0 } })
|
||||
|
||||
let browser, confirmation = null, version = 0, saved = null, failNext = false
|
||||
try {
|
||||
await server.listen()
|
||||
browser = await chromium.launch({ headless: true, channel: 'msedge' })
|
||||
const page = await browser.newPage({ viewport: { width: 1024, height: 768 } })
|
||||
await page.route(url => url.pathname.startsWith('/api/'), async route => {
|
||||
const url = new URL(route.request().url())
|
||||
if (route.request().method() === 'PUT') {
|
||||
const input = route.request().postDataJSON()
|
||||
saved = input
|
||||
version++
|
||||
const released = url.pathname.endsWith('/release')
|
||||
confirmation = { disposition: released ? 'RELEASED' : input.disposition, evidence: input.evidence, current: !released,
|
||||
shipment: released ? null : { source_id: 'SHIP1', source_no: '发货1' },
|
||||
order: released ? null : { source_id: 'ORDER2', source_no: '订单2' } }
|
||||
return route.fulfill({ json: { code: 200, message: 'success', data: { item_id: 1, version } } })
|
||||
}
|
||||
if (failNext) {
|
||||
failNext = false
|
||||
return route.fulfill({ status: 500, json: { code: 500, message: '加载失败' } })
|
||||
}
|
||||
const shipPage = Number(url.searchParams.get('shipment_page'))
|
||||
const orderPage = Number(url.searchParams.get('order_page'))
|
||||
const item = { item_id: 1, product_name: '测试产品', spec: '规格', quantity: '2', amount: '200', item_revision: 'a'.repeat(64), version, confirmation,
|
||||
shipments: [{ document_id: shipPage, source_id: `SHIP${shipPage}`, source_no: `发货${shipPage}`, date: '2026-03-10', revision: 'b'.repeat(64) }],
|
||||
shipments_truncated: shipPage === 1,
|
||||
orders: [{ document_id: 100 + orderPage, source_id: `ORDER${orderPage}`, quantity: '2', amount: '200', revision: 'c'.repeat(64) }], orders_truncated: orderPage === 1 }
|
||||
await route.fulfill({ json: { code: 200, message: 'success', data: { list: [item], total: 1 } } })
|
||||
})
|
||||
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__relations-proof`)
|
||||
await page.getByText('测试产品').waitFor()
|
||||
const row = page.locator('.relation-row')
|
||||
await row.locator('.el-select').nth(0).click()
|
||||
await page.getByRole('option', { name: /发货1/ }).click()
|
||||
await row.locator('.el-select').nth(1).click()
|
||||
await page.getByRole('option', { name: '对应订单发货' }).click()
|
||||
await row.getByRole('button', { name: '下一页' }).nth(1).click()
|
||||
await page.getByText('订单候选第 2 页').waitFor()
|
||||
await row.locator('.el-select').nth(2).click()
|
||||
await page.getByRole('option', { name: /ORDER2/ }).click()
|
||||
await row.locator('input[placeholder="填写依据"]').fill('原单核对')
|
||||
await row.getByRole('button', { name: '保存核对' }).click()
|
||||
await page.getByText(/已确认/).waitFor()
|
||||
assert.match(await row.innerText(), /发货1.*SHIP1.*订单2.*ORDER2/)
|
||||
assert.equal(saved.shipment_document_id, 1)
|
||||
assert.equal(saved.order_document_id, 102)
|
||||
assert.equal(saved.evidence, '原单核对')
|
||||
await row.getByRole('button', { name: '撤销核对' }).click()
|
||||
await page.getByPlaceholder('填写撤销依据').fill('重新核实')
|
||||
await page.getByRole('button', { name: '撤销核对' }).last().click()
|
||||
await page.getByText(/已撤销/).waitFor()
|
||||
assert.equal(saved.expected_version, 1)
|
||||
assert.equal(saved.evidence, '重新核实')
|
||||
await page.setViewportSize({ width: 375, height: 812 })
|
||||
assert.ok(await page.locator('.relation-row').evaluate(el => el.getBoundingClientRect().right <= innerWidth))
|
||||
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__relations-proof?no-permission=1`)
|
||||
await page.getByText('测试产品').waitFor()
|
||||
assert.equal(await page.getByRole('button', { name: '保存核对' }).isDisabled(), true)
|
||||
failNext = true
|
||||
await page.reload()
|
||||
await page.getByText('列表加载失败,请重试').waitFor()
|
||||
await page.getByRole('button', { name: '重新加载' }).click()
|
||||
await page.getByText('测试产品').waitFor()
|
||||
process.stdout.write('人工关系跨页选择、确认、撤销、权限、失败重试及窄屏的模拟浏览器回归通过。\n')
|
||||
} finally {
|
||||
await browser?.close()
|
||||
await server.close()
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/** 来源业务详情加载、错误及重试;接口为隔离模拟。 */
|
||||
import assert from 'node:assert/strict'
|
||||
import path from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { createServer } from 'vite'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
|
||||
const root = fileURLToPath(new URL('../', import.meta.url))
|
||||
const id = 'virtual:source-documents-proof'
|
||||
const harness = `import {createApp,h} from 'vue';import {createPinia} from 'pinia';import ElementPlus from 'element-plus';import 'element-plus/dist/index.css';import Source from '/src/views/business/businesses/SourceDocuments.vue';import {useUserStore} from '/src/store/user.ts';const pinia=createPinia();createApp({setup(){window.__sourceUser=useUserStore();window.__sourceUser.userInfo={permissions:['business:businesses:update','payroll:prepare']};return()=>h(Source,{businessId:12})}}).use(pinia).use(ElementPlus).mount('#app')`
|
||||
const server = await createServer({ configFile: false, root,
|
||||
resolve: { alias: { '@': path.join(root, 'src') } },
|
||||
define: { 'import.meta.env.VITE_API_BASE_URL': JSON.stringify('/api') },
|
||||
plugins: [vue(), { name: 'source-documents-proof', resolveId: value => value === id ? '\0' + id : undefined,
|
||||
load: value => value === '\0' + id ? harness : undefined,
|
||||
configureServer(instance) { instance.middlewares.use('/__source-proof', async (_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
response.end(await instance.transformIndexHtml('/__source-proof', '<!doctype html><html lang="zh-CN"><meta charset="UTF-8"><div id="app"></div><script type="module" src="/@id/__x00__virtual:source-documents-proof"></script></html>'))
|
||||
}) },
|
||||
}], server: { host: '127.0.0.1', port: 0 } })
|
||||
|
||||
let browser, failNext = false, failDetail = false, failQuantity = false, repaired = false, repairRequests = 0
|
||||
try {
|
||||
await server.listen()
|
||||
browser = await chromium.launch({ headless: true, channel: 'msedge' })
|
||||
const page = await browser.newPage()
|
||||
await page.route(url => url.pathname.startsWith('/api/'), async route => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname.endsWith('/missing-price')) {
|
||||
repairRequests++
|
||||
const body = route.request().postDataJSON()
|
||||
assert.equal(body.revision, 'a'.repeat(64))
|
||||
assert.equal(body.evidence, '已核对原单')
|
||||
repaired = true
|
||||
return route.fulfill({ json: { code: 200, message: 'success', data: { document_id: 2, parent_status: 'APPLIED' } } })
|
||||
}
|
||||
if (url.pathname.endsWith('/source-quantities') && failQuantity) {
|
||||
failQuantity = false
|
||||
return route.fulfill({ status: 500, json: { code: 500, message: '加载失败' } })
|
||||
}
|
||||
if (url.pathname.endsWith('/details') && failDetail) {
|
||||
failDetail = false
|
||||
return route.fulfill({ status: 500, json: { code: 500, message: '加载失败' } })
|
||||
}
|
||||
if (url.pathname.endsWith('/source-documents') && failNext) {
|
||||
failNext = false
|
||||
return route.fulfill({ status: 500, json: { code: 500, message: '加载失败' } })
|
||||
}
|
||||
const data = url.pathname.endsWith('/details')
|
||||
? { list: [
|
||||
{ source_id: '12', state: 'CHANGED', before: { product_id: '9', quantity: '2', amount: '100', price: '50', price_type: '含税' }, after: { product_id: '9', quantity: '3', amount: '150', price: '50', price_type: '未税' }, changes: [{ field: 'quantity', label: '数量', before: '2', after: '3' }] },
|
||||
{ source_id: '13', state: 'ADDED', before: null, after: { product_id: '9', quantity: '1', amount: '50', price: '10.12345', price_type: '未税' }, changes: [] },
|
||||
], total: 2 }
|
||||
: url.pathname.endsWith('/readiness')
|
||||
? { status: 'PENDING', reasons: [], customer_type: 'personal', products: [], products_total: 0, eligible_receipts_snapshot: null }
|
||||
: url.pathname.endsWith('/source-quantities')
|
||||
? { list: [{ product_id: 9, product_name: '测试管材', spec_model: 'DN300', unit: '根', order_quantity: '10.00', shipment_quantity: '4.00' }], total: 1 }
|
||||
: { list: [{ id: 1, entity: 'businesses', source_no: 'SO-001', current_source_no: 'SO-002', status: 'CONFLICT',
|
||||
changes: [{ field: 'amount', label: '金额', before: '100', after: '200' }] },
|
||||
{ id: 2, entity: 'shipment_items', source_no: 'L-1', snapshot_state: 'CHANGED', price: '10.1234', price_type: 'TAX_EXCLUDED', price_repair_revision: repaired ? null : 'a'.repeat(64), changes: [] },
|
||||
{ id: 3, entity: 'shipment_items', source_no: 'L-2', snapshot_state: 'MISSING', price: '12.3400', price_type: 'TAX_INCLUDED', changes: [] },
|
||||
{ id: 4, entity: 'shipment_items', source_no: 'L-3', snapshot_state: 'UNAVAILABLE', price: '5.00', changes: [] },
|
||||
{ id: 5, entity: 'receipts', source_no: 'RC-001', status: 'CONFLICT', reason: 'PENDING_SOURCE_CUSTOMER_CHANGED',
|
||||
changes: [{ field: 'customer_id', label: '客户标识', before: '8', after: '10' }] },
|
||||
{ id: 6, entity: 'receipts', source_no: 'RC-002', status: 'CONFLICT', reason: 'PENDING_SOURCE_OUT_OF_RANGE',
|
||||
changes: [{ field: 'date', label: '日期', before: '2026-03-14', after: '2026-02-28' }] }], total: 21 }
|
||||
await route.fulfill({ json: { code: 200, message: 'success', data } })
|
||||
})
|
||||
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__source-proof`)
|
||||
await page.getByText('SO-001').waitFor()
|
||||
await page.getByText('SO-002').waitFor()
|
||||
await page.getByText('10.1234(未税)').waitFor()
|
||||
await page.getByText('12.3400(含税)').waitFor()
|
||||
await page.getByText('5.00(税别待确认)').waitFor()
|
||||
await page.evaluate(() => { window.__sourceUser.userInfo.permissions = ['business:businesses:update'] })
|
||||
assert.equal(await page.getByRole('button', { name: '核对单价' }).count(), 0)
|
||||
await page.evaluate(() => { window.__sourceUser.userInfo.permissions = ['business:businesses:update', 'payroll:prepare'] })
|
||||
await page.getByRole('button', { name: '核对单价' }).click()
|
||||
await page.getByRole('button', { name: '取消' }).click()
|
||||
assert.equal(repairRequests, 0)
|
||||
await page.getByRole('button', { name: '核对单价' }).click()
|
||||
await page.getByPlaceholder('填写核对依据').fill('已核对原单')
|
||||
await page.getByRole('button', { name: '确认补齐' }).click()
|
||||
await page.getByText('来源单价已补齐').waitFor()
|
||||
assert.equal(repairRequests, 1)
|
||||
const quantityRow = page.getByRole('row').filter({ hasText: '测试管材' })
|
||||
await quantityRow.getByText('10.00', { exact: true }).waitFor()
|
||||
await quantityRow.getByText('4.00', { exact: true }).waitFor()
|
||||
await page.getByText('金额:100 → 200').waitFor()
|
||||
await page.getByText('待核对单据的来源客户已变化,原关联已保留,请核对原单').waitFor()
|
||||
await page.getByText('客户标识:8 → 10').waitFor()
|
||||
await page.getByText('待核对单据的来源日期已早于接入起点,原关联已保留,请核对原单').waitFor()
|
||||
await page.getByText('日期:2026-03-14 → 2026-02-28').waitFor()
|
||||
failQuantity = true
|
||||
await page.reload()
|
||||
await page.getByText('SO-001').waitFor()
|
||||
await page.locator('.quantity-section').getByText('列表加载失败,请重试').waitFor()
|
||||
await page.locator('.quantity-section').getByRole('button', { name: '重新加载' }).click()
|
||||
await page.getByRole('row').filter({ hasText: '测试管材' }).getByText('10.00', { exact: true }).waitFor()
|
||||
await page.getByText('来源明细已变化,待核对').waitFor()
|
||||
await page.getByText('最新完整快照中没有此明细').waitFor()
|
||||
await page.getByText('完整快照不可用,显示已接入明细').waitFor()
|
||||
await page.getByRole('button', { name: '查看明细' }).first().click()
|
||||
const dialog = page.getByRole('dialog', { name: '来源明细对照' })
|
||||
await dialog.getByText('数量:2 → 3').waitFor()
|
||||
await dialog.getByText('产品 9,数量 1,单价 10.12345(未税),金额 50').waitFor()
|
||||
await dialog.getByText('新增', { exact: true }).waitFor()
|
||||
await dialog.locator('.el-dialog__headerbtn').click()
|
||||
failDetail = true
|
||||
await page.setViewportSize({ width: 375, height: 800 })
|
||||
await page.getByText('客户标识:8 → 10').waitFor()
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth), true)
|
||||
await page.getByRole('button', { name: '查看明细' }).first().click()
|
||||
await dialog.getByText('列表加载失败,请重试').waitFor()
|
||||
await dialog.getByRole('button', { name: '重新加载' }).click()
|
||||
await dialog.getByText('数量:2 → 3').waitFor()
|
||||
await dialog.locator('.el-dialog__headerbtn').click()
|
||||
assert.match(await page.locator('.source-section').innerText(), /可参与平账回款.*待确认/)
|
||||
failNext = true
|
||||
await page.locator('.el-pagination button.btn-next').click()
|
||||
await page.getByText('列表加载失败,请重试').waitFor()
|
||||
assert.equal(await page.getByText('SO-001').count(), 0)
|
||||
await page.getByRole('button', { name: '重新加载' }).click()
|
||||
await page.getByText('SO-001').waitFor()
|
||||
assert.equal(await page.getByText('列表加载失败,请重试').count(), 0)
|
||||
process.stdout.write('来源详情加载失败、清除旧数据和重试通过。\n')
|
||||
} finally {
|
||||
await browser?.close()
|
||||
await server.close()
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/** 来源业务核算准备组件回归;所有接口均为隔离模拟。 */
|
||||
import assert from 'node:assert/strict'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { createServer } from 'vite'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'C:/Users/Administrator/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
|
||||
const root = fileURLToPath(new URL('../', import.meta.url))
|
||||
const output = path.resolve(root, '../.ai/tplus-payroll-browser')
|
||||
fs.mkdirSync(output, { recursive: true })
|
||||
const id = 'virtual:tplus-payroll-proof'
|
||||
const harness = `import {createApp,h,ref} from 'vue';import {createPinia} from 'pinia';import ElementPlus from 'element-plus';import 'element-plus/dist/index.css';import '/src/assets/styles/index.css';import Dialog from '/src/views/business/businesses/PayrollDialog.vue';import {useUserStore} from '/src/store/user.ts';const pinia=createPinia();createApp({setup(){useUserStore().userInfo={permissions:['payroll:view','payroll:prepare','payroll:calculate']};const open=ref(false);setTimeout(()=>open.value=true,0);return()=>h(Dialog,{modelValue:open.value,'onUpdate:modelValue':v=>open.value=v,businessId:12,sourceManaged:true})}}).use(pinia).use(ElementPlus).mount('#app')`
|
||||
const server = await createServer({ configFile: false, root,
|
||||
resolve: { alias: { '@': path.join(root, 'src') } },
|
||||
define: { 'import.meta.env.VITE_API_BASE_URL': JSON.stringify('/api') },
|
||||
cacheDir: path.join(output, 'vite-cache'),
|
||||
plugins: [vue(), { name: 'tplus-payroll-proof', resolveId: value => value === id ? '\0' + id : undefined,
|
||||
load: value => value === '\0' + id ? harness : undefined,
|
||||
configureServer(instance) { instance.middlewares.use('/__payroll-proof', async (_request, response) => {
|
||||
response.setHeader('Content-Type', 'text/html; charset=utf-8')
|
||||
response.end(await instance.transformIndexHtml('/__payroll-proof', '<!doctype html><html lang="zh-CN"><meta charset="UTF-8"><div id="app"></div><script type="module" src="/@id/__x00__virtual:tplus-payroll-proof"></script></html>'))
|
||||
}) },
|
||||
}], server: { host: '127.0.0.1', port: 0 }, optimizeDeps: { include: ['vue', 'element-plus', 'pinia'] } })
|
||||
|
||||
let browser, snapshot = null, submitted = null, large = false, failLargePage = false
|
||||
const largePeople = Array.from({length: 205}, (_, index) => ({id: index + 1, personnel_name: `分页业务员${index + 1}`}))
|
||||
const largeShipments = Array.from({length: 205}, (_, index) => ({id: index + 101, shipment_no: `SH-${index + 101}`,
|
||||
product_name: `分页产品${index + 1}`, ship_date: '2026-03-10', amount: '100.00'}))
|
||||
const errors = []
|
||||
try {
|
||||
await server.listen()
|
||||
browser = await chromium.launch({ headless: true, channel: 'msedge' })
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, reducedMotion: 'reduce' })
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
await page.route(url => url.pathname.startsWith('/api/'), async route => {
|
||||
const req = route.request(), pathname = new URL(req.url()).pathname
|
||||
let data
|
||||
if (pathname.endsWith('/input') && req.method() === 'POST') {
|
||||
submitted = req.postDataJSON()
|
||||
snapshot = { id: 1, version: 1, source: 'test_source', fingerprint: 'test', data: {
|
||||
group: { opening_balance: submitted.opening_balance }, evidence: submitted.evidence,
|
||||
facts: { handovers: submitted.handovers ?? snapshot?.data?.facts?.handovers ?? [] },
|
||||
shipments: submitted.shipment_ownerships.map(row => ({ line_id: String(row.shipment_item_id),
|
||||
personnel_id: String(row.personnel_id), manager_id: null, unit_cost: row.unit_cost, freight_amount: row.freight_amount })) } }
|
||||
data = { version: 1 }
|
||||
} else if (pathname.endsWith('/input')) data = snapshot
|
||||
else if (pathname.includes('/meta/payroll-input/') && large) {
|
||||
const pageNumber = Number(new URL(req.url()).searchParams.get('pageNum') || 1)
|
||||
if (pageNumber === 2 && failLargePage) return route.fulfill({status: 500, json: {code: 500, message: '加载失败'}})
|
||||
const start = (pageNumber - 1) * 200
|
||||
data = { business_key: 'customer:8', personnel: largePeople.slice(start, start + 200), personnel_total: largePeople.length,
|
||||
shipments: largeShipments.slice(start, start + 200), shipments_total: largeShipments.length }
|
||||
} else if (pathname.includes('/meta/payroll-input/')) data = { business_key: 'customer:8', personnel: [{id: 1, personnel_name: '测试业务员'}, {id: 2, personnel_name: '接手业务员'}], shipments: [
|
||||
{id: 101, shipment_no: 'SH-001', product_name: '测试管材', ship_date: '2026-03-10', amount: '100.00', source_personnel_name: '来源员工', matched_personnel_id: 1, guidance_status: 'FINALIZED_UNIT', guidance_unit_cost: '25'},
|
||||
...(snapshot ? [{id: 102, shipment_no: 'SH-002', product_name: '追加发货', ship_date: '2026-03-11', amount: '50.00', source_personnel_name: '未匹配员工', matched_personnel_id: null}] : []),
|
||||
] }
|
||||
else if (pathname.includes('/months/')) data = { month: '2026-03', allocation_locked: false, maker_name: null }
|
||||
else throw new Error('未预期接口:' + pathname)
|
||||
await route.fulfill({ json: { code: 200, message: 'success', data } })
|
||||
})
|
||||
await page.goto(`http://127.0.0.1:${server.httpServer.address().port}/__payroll-proof`)
|
||||
const dialog = page.getByRole('dialog', { name: '正式绩效核算' })
|
||||
await dialog.getByText('测试管材', { exact: false }).first().waitFor()
|
||||
await dialog.getByText('来源员工', { exact: true }).waitFor()
|
||||
await dialog.locator('tr').filter({hasText: '测试管材'}).getByText('测试业务员', {exact: true}).waitFor()
|
||||
await dialog.locator('.el-date-editor input').fill('2026-03')
|
||||
await dialog.locator('.el-date-editor input').press('Enter')
|
||||
await page.waitForResponse(response => response.url().includes('/months/2026-03'))
|
||||
await dialog.locator('.el-form-item').filter({hasText: '期初应收'}).locator('input').fill('0')
|
||||
await dialog.locator('.el-form-item').filter({hasText: '核对依据'}).locator('input').fill('已核实来源')
|
||||
await dialog.getByRole('combobox', {name: '发货归属人员'}).first().click()
|
||||
await page.getByRole('option', {name: '测试业务员'}).click()
|
||||
assert.equal(await dialog.getByRole('textbox', {name: '含税单位指导成本'}).first().inputValue(), '25')
|
||||
assert.equal(await dialog.getByRole('textbox', {name: '含税单位指导成本'}).first().isDisabled(), true)
|
||||
await dialog.getByText('使用已封存的月度指导单价', {exact: true}).waitFor()
|
||||
await dialog.getByRole('textbox', {name: '发货明细运费'}).first().fill('0')
|
||||
await dialog.getByRole('button', {name: '添加交接'}).click()
|
||||
await dialog.locator('.el-form-item').filter({hasText: '交接类型'}).locator('.el-select').click()
|
||||
await page.getByRole('option', {name: '在职交接'}).click()
|
||||
await dialog.getByRole('combobox', {name: '移交人员'}).click()
|
||||
await page.getByRole('option', {name: '测试业务员'}).click()
|
||||
await dialog.getByRole('combobox', {name: '接手人员'}).click()
|
||||
await page.getByRole('option', {name: '接手业务员'}).click()
|
||||
await dialog.locator('.el-form-item').filter({hasText: '生效日期'}).locator('input').fill('2026-03-20')
|
||||
await dialog.locator('.el-form-item').filter({hasText: '生效日期'}).locator('input').press('Enter')
|
||||
await dialog.locator('label.el-checkbox').filter({hasText: '已核实期初'}).click()
|
||||
await dialog.getByRole('button', {name: '保存核算资料'}).click()
|
||||
assert.equal(submitted, null)
|
||||
await dialog.locator('label.el-checkbox').filter({hasText: '已逐次核实发货归属'}).click()
|
||||
await dialog.getByRole('button', {name: '保存核算资料'}).click()
|
||||
await dialog.getByText('追加发货', {exact: false}).first().waitFor()
|
||||
assert.equal(submitted.shipment_ownerships[0].freight_amount, '0')
|
||||
assert.equal(submitted.shipment_ownerships[0].personnel_id, 1)
|
||||
assert.deepEqual(submitted.handovers, [{business_key: 'customer:8', mode: 'ACTIVE', from_personnel_id: '1',
|
||||
to_personnel_id: '2', effective_date: '2026-03-20', shipping_ownership_confirmed: true}])
|
||||
await dialog.locator('.el-form-item').filter({hasText: '移交人员'}).getByText('测试业务员').waitFor()
|
||||
const freight = dialog.getByRole('textbox', {name: '发货明细运费'})
|
||||
assert.equal(await freight.nth(0).isDisabled(), true)
|
||||
assert.equal(await freight.nth(1).isEnabled(), true)
|
||||
await dialog.getByText('未匹配员工(待匹配)', {exact: true}).waitFor()
|
||||
await dialog.getByRole('combobox', {name: '发货归属人员'}).nth(1).click()
|
||||
await page.getByRole('option', {name: '测试业务员'}).click()
|
||||
await dialog.getByRole('textbox', {name: '含税单位指导成本'}).nth(1).fill('25')
|
||||
await freight.nth(1).fill('0')
|
||||
submitted = null
|
||||
const secondSave = page.waitForResponse(response => response.url().endsWith('/input') && response.request().method() === 'POST')
|
||||
await dialog.getByRole('button', {name: '保存核算资料'}).click()
|
||||
await secondSave
|
||||
assert.equal(Object.hasOwn(submitted, 'handovers'), false)
|
||||
assert.equal(snapshot.data.facts.handovers.length, 1)
|
||||
for (const width of [1440, 768, 390]) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await page.screenshot({ path: path.join(output, `payroll-${width}.png`), fullPage: true })
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false)
|
||||
}
|
||||
snapshot.data.shipments[0].personnel_id = '2'
|
||||
await page.reload()
|
||||
await dialog.locator('tr').filter({hasText: '测试管材'}).getByText('接手业务员', {exact: true}).waitFor()
|
||||
assert.equal(await dialog.getByRole('combobox', {name: '发货归属人员'}).first().isDisabled(), true)
|
||||
large = true
|
||||
failLargePage = true
|
||||
snapshot = { id: 1, version: 2, source: 'test_source', fingerprint: 'test', data: {
|
||||
group: { opening_balance: '0' }, evidence: '分页依据',
|
||||
shipments: largeShipments.map((row, index) => ({line_id: String(row.id), personnel_id: String(index + 1),
|
||||
manager_id: null, unit_cost: '25', freight_amount: '0'})) } }
|
||||
submitted = null
|
||||
await page.reload()
|
||||
await dialog.getByText('资料加载失败,请重试。').waitFor()
|
||||
assert.equal(submitted, null)
|
||||
failLargePage = false
|
||||
await dialog.getByRole('button', {name: '重新加载'}).click()
|
||||
await dialog.getByText('分页产品1', {exact: false}).first().waitFor()
|
||||
await dialog.locator('.el-pagination .number').getByText('5', {exact: true}).click()
|
||||
await dialog.getByText('分页产品205', {exact: false}).waitFor()
|
||||
await dialog.getByText('分页业务员205', {exact: true}).waitFor()
|
||||
await dialog.locator('.el-date-editor input').fill('2026-03')
|
||||
await dialog.locator('.el-date-editor input').press('Enter')
|
||||
await page.waitForResponse(response => response.url().includes('/months/2026-03'))
|
||||
await dialog.locator('label.el-checkbox').filter({hasText: '已核实期初'}).click()
|
||||
await dialog.getByRole('button', {name: '保存核算资料'}).click()
|
||||
await page.waitForFunction(() => document.body.innerText.includes('核算资料已保存为新版本'))
|
||||
assert.equal(submitted.shipment_ownerships.length, 205)
|
||||
assert.equal(submitted.shipment_ownerships[204].personnel_id, 205)
|
||||
assert.deepEqual(errors, [])
|
||||
process.stdout.write('来源核算分页、加载失败、完整提交及响应式检查通过。\n')
|
||||
} finally {
|
||||
await browser?.close()
|
||||
await server.close()
|
||||
}
|
||||
@@ -14,7 +14,7 @@ export interface BusinessItemRow {
|
||||
spec_model: string | null
|
||||
unit: string | null
|
||||
/** municipal_water 市政水利 / rail_transit 轨道交通 —— 行级折算锚点 */
|
||||
product_category: string
|
||||
product_category: string | null
|
||||
/** 数字字符串,>0 */
|
||||
quantity: string
|
||||
sale_price: string | null
|
||||
@@ -48,6 +48,12 @@ export interface BusinessItem {
|
||||
total_received_amount: string | null
|
||||
total_freight_amount: string | null
|
||||
remark: string | null
|
||||
source_type: string
|
||||
source_no: string | null
|
||||
source_personnel: string[]
|
||||
integration_status: string | null
|
||||
readiness_status: 'READY' | 'PENDING' | null
|
||||
readiness_reasons: { code: string; message: string }[]
|
||||
}
|
||||
|
||||
/** 二次分配参与人(2026-10-15 补充需求;比例不强制合计 100%) */
|
||||
@@ -85,12 +91,120 @@ export interface BusinessQueryParams {
|
||||
status?: number
|
||||
customer_id?: number
|
||||
owner_personnel_id?: number
|
||||
source_type?: string
|
||||
source_personnel?: string
|
||||
pending_only?: boolean
|
||||
readiness_status?: 'READY' | 'PENDING'
|
||||
readiness_reason?: string
|
||||
/** 业务日期起(含),ISO 串 */
|
||||
date_from?: string
|
||||
/** 业务日期止(含),ISO 串 */
|
||||
date_to?: string
|
||||
}
|
||||
|
||||
export interface SourceReadiness {
|
||||
source_type: string
|
||||
status: string
|
||||
reasons: { code: string; message: string }[]
|
||||
customer_type: string | null
|
||||
products: { id: number; name: string; spec: string; category: string | null }[]
|
||||
products_total: number
|
||||
eligible_receipts_snapshot: string | null
|
||||
}
|
||||
|
||||
export interface SourceDocument {
|
||||
id: number
|
||||
entity: string
|
||||
source_no: string | null
|
||||
current_source_no: string | null
|
||||
snapshot_state: 'CURRENT' | 'CHANGED' | 'MISSING' | 'UNAVAILABLE' | null
|
||||
changes: { field: string; label: string; before: string | null; after: string | null }[]
|
||||
status: string
|
||||
reason: string | null
|
||||
date: string | null
|
||||
personnel_name: string | null
|
||||
personnel_source_id: string | null
|
||||
quantity: string | null
|
||||
amount: string | null
|
||||
price: string | null
|
||||
price_type: 'TAX_INCLUDED' | 'TAX_EXCLUDED' | null
|
||||
price_repair_revision: string | null
|
||||
}
|
||||
|
||||
export function sourceReadiness(id: number): Promise<SourceReadiness> {
|
||||
return request({ url: `/v1/business/businesses/${id}/readiness` })
|
||||
}
|
||||
|
||||
export function sourceDocuments(id: number, params: { pageNum: number; pageSize: number }): Promise<PageData<SourceDocument>> {
|
||||
return request({ url: `/v1/business/businesses/${id}/source-documents`, params })
|
||||
}
|
||||
|
||||
export function confirmMissingSourcePrice(id: number, documentId: number, data: { revision: string; evidence: string }): Promise<{ document_id: number; parent_status: string }> {
|
||||
return request({ url: `/v1/business/businesses/${id}/source-documents/${documentId}/missing-price`, method: 'put', data })
|
||||
}
|
||||
|
||||
export interface SourceQuantity {
|
||||
product_id: number | null
|
||||
product_name: string
|
||||
spec_model: string
|
||||
unit: string | null
|
||||
order_quantity: string
|
||||
shipment_quantity: string
|
||||
}
|
||||
|
||||
export function sourceQuantities(id: number, params: { pageNum: number; pageSize: number }): Promise<PageData<SourceQuantity>> {
|
||||
return request({ url: `/v1/business/businesses/${id}/source-quantities`, params })
|
||||
}
|
||||
|
||||
export interface SourceDocumentDetail {
|
||||
source_id: string
|
||||
state: 'ADDED' | 'REMOVED' | 'CHANGED' | 'UNCHANGED'
|
||||
changes: { field: string; label: string; before: string | null; after: string | null }[]
|
||||
before: Record<string, string | number | null> | null
|
||||
after: Record<string, string | number | null> | null
|
||||
}
|
||||
|
||||
export function sourceDocumentDetails(id: number, documentId: number, params: { pageNum: number; pageSize: number }): Promise<PageData<SourceDocumentDetail>> {
|
||||
return request({ url: `/v1/business/businesses/${id}/source-documents/${documentId}/details`, params })
|
||||
}
|
||||
|
||||
export interface SalesRelationCandidate {
|
||||
item_id: number
|
||||
product_name: string
|
||||
spec: string | null
|
||||
quantity: string
|
||||
amount: string
|
||||
item_revision: string
|
||||
version: number
|
||||
confirmation: { disposition: string; evidence: string; current: boolean;
|
||||
shipment: { source_id: string; source_no: string | null } | null
|
||||
order: { source_id: string; source_no: string | null } | null } | null
|
||||
shipments: { document_id: number; source_id: string; source_no: string | null; date: string; revision: string }[]
|
||||
shipments_truncated: boolean
|
||||
orders: { document_id: number; source_id: string; quantity: string; amount: string; revision: string }[]
|
||||
orders_truncated: boolean
|
||||
}
|
||||
|
||||
export function salesRelationCandidates(id: number, params: { pageNum: number; pageSize: number; shipment_page: number; order_page: number }): Promise<PageData<SalesRelationCandidate>> {
|
||||
return request({ url: `/v1/business/businesses/${id}/sales-relation-candidates`, params })
|
||||
}
|
||||
|
||||
export function confirmSalesRelation(id: number, data: {
|
||||
item_id: number; shipment_document_id: number; order_document_id: number | null
|
||||
disposition: 'ORDER' | 'INDEPENDENT'; item_revision: string; shipment_revision: string
|
||||
order_revision: string | null; expected_version: number; evidence: string
|
||||
}): Promise<{ item_id: number; version: number }> {
|
||||
return request({ url: `/v1/business/businesses/${id}/sales-relation`, method: 'put', data })
|
||||
}
|
||||
|
||||
export function releaseSalesRelation(id: number, data: { item_id: number; expected_version: number; evidence: string }): Promise<{ item_id: number; version: number }> {
|
||||
return request({ url: `/v1/business/businesses/${id}/sales-relation/release`, method: 'put', data })
|
||||
}
|
||||
|
||||
export function saveSourceMaterials(id: number, data: { customer_type?: string; products: { product_id: number; category: string }[] }): Promise<SourceReadiness> {
|
||||
return request({ url: `/v1/business/businesses/${id}/source-materials`, method: 'put', data })
|
||||
}
|
||||
|
||||
/** 明细行入参(product_id 空 = 自由录入;金额/数量为数字字符串) */
|
||||
export interface BusinessItemParams {
|
||||
product_id?: number | null
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface CustomerItem {
|
||||
address: string | null
|
||||
industry: string | null
|
||||
/** personal 个人户 / manager_personal 经理个人户 / enterprise 企业户(计提规则锚点) */
|
||||
customer_type: string
|
||||
customer_type: string | null
|
||||
owner_personnel_id: number | null
|
||||
owner_personnel_name: string | null
|
||||
remark: string | null
|
||||
@@ -33,7 +33,9 @@ export interface CustomerQueryParams {
|
||||
/** 公司名称或客户负责人,模糊匹配 */
|
||||
keyword?: string
|
||||
customer_type?: string
|
||||
category_missing?: boolean
|
||||
owner_personnel_id?: number
|
||||
owner_missing?: boolean
|
||||
/** 排序键(后端白名单:id 建档序 / rating_score 评分),默认 id 倒序 */
|
||||
order_by?: 'id' | 'rating_score'
|
||||
/** 排序方向,默认 desc */
|
||||
@@ -98,8 +100,8 @@ export const CUSTOMER_TYPE_OPTIONS = [
|
||||
] as const
|
||||
|
||||
/** 分类编码 → 展示名(表格列展示用,未知编码原样回显) */
|
||||
export function customerTypeLabel(code: string): string {
|
||||
return CUSTOMER_TYPE_OPTIONS.find((o) => o.value === code)?.label ?? code
|
||||
export function customerTypeLabel(code: string | null): string {
|
||||
return code ? CUSTOMER_TYPE_OPTIONS.find((o) => o.value === code)?.label ?? code : '待确认'
|
||||
}
|
||||
|
||||
/** 分页查询客户(keyword 对公司名称/客户负责人模糊匹配,附归属人员姓名) */
|
||||
@@ -107,6 +109,14 @@ export function listCustomers(params: CustomerQueryParams): Promise<PageData<Cus
|
||||
return request({ url: '/v1/base/customers', method: 'get', params })
|
||||
}
|
||||
|
||||
export function batchCustomerCategory(ids: number[], category: string): Promise<{ updated: number }> {
|
||||
return request({ url: '/v1/base/customers/batch-category', method: 'put', data: { ids, category } })
|
||||
}
|
||||
|
||||
export function batchCustomerOwnership(ids: number[], new_owner_personnel_id: number): Promise<{ updated: number }> {
|
||||
return request({ url: '/v1/base/customers/batch-ownership', method: 'put', data: { ids, new_owner_personnel_id } })
|
||||
}
|
||||
|
||||
/** 新增客户(公司名称+负责人组合唯一,冲突由后端拦截) */
|
||||
export function createCustomer(data: CustomerCreateParams): Promise<CustomerItem> {
|
||||
return request({ url: '/v1/base/customers', method: 'post', data })
|
||||
|
||||
@@ -3,12 +3,13 @@ import type { PageData } from './types'
|
||||
|
||||
export interface PayrollInput { id: number; version: number; source: string; fingerprint: string; data: Record<string, unknown> }
|
||||
export interface MonthState { month: string; allocation_locked: boolean; maker_name: string | null }
|
||||
export interface Ownership { shipment_item_id: number; personnel_id?: number; manager_id?: number; unit_cost: string }
|
||||
export interface Ownership { shipment_item_id: number; personnel_id?: number; manager_id?: number; unit_cost: string; freight_amount?: string }
|
||||
export interface Handover { business_key: string; mode: 'RESIGNATION' | 'ACTIVE'; from_personnel_id: string; to_personnel_id: string; effective_date: string; shipping_ownership_confirmed: boolean }
|
||||
export interface CalculationResult { month: string; business_id: number; ledger_ids: number[]; performance: string; payable: string; start_payment_ok: boolean }
|
||||
const base = '/v1/payroll'
|
||||
export const getInput = (id: number): Promise<PayrollInput | null> => request({url: `${base}/businesses/${id}/input`})
|
||||
export const monthState = (month: string): Promise<MonthState> => request({url: `${base}/months/${month}`})
|
||||
export const saveInput = (id: number, data: {month: string; expected_version: number; evidence: string; opening_balance: string; history_confirmed: true; guidance_finalized: true; shipment_ownerships: Ownership[]}): Promise<{version: number}> => request({url: `${base}/businesses/${id}/input`, method: 'post', data})
|
||||
export const saveInput = (id: number, data: {month: string; expected_version: number; evidence: string; opening_balance: string; history_confirmed: true; guidance_finalized: true; shipment_ownerships: Ownership[]; handovers?: Handover[]}): Promise<{version: number}> => request({url: `${base}/businesses/${id}/input`, method: 'post', data})
|
||||
export const calculate = (id: number, month: string, reason?: string): Promise<CalculationResult> => request({url: `${base}/businesses/${id}/calculate`, method: 'post', data: {month, reason}})
|
||||
export const allocations = (ledgerId: number): Promise<PageData<Record<string, unknown>>> => request({url: `${base}/ledger/${ledgerId}/allocations`, params: {pageSize: 200}})
|
||||
export const allocationAdjustment = (data: {ledger_id: number; personnel_id: number; direction: number; amount: string; reason: string}): Promise<unknown> => request({url: `${base}/allocation-adjustments`, method: 'post', data})
|
||||
@@ -18,7 +18,7 @@ export interface ProductItem {
|
||||
stock_quantity: string | null
|
||||
sale_price: string | null
|
||||
/** municipal_water 市政水利 / rail_transit 轨道交通 —— 折算阈值的行级锚点 */
|
||||
product_category: string
|
||||
product_category: string | null
|
||||
/** 1启用 0停用 */
|
||||
status: number
|
||||
remark: string | null
|
||||
@@ -32,6 +32,7 @@ export interface ProductQueryParams {
|
||||
/** 名称或规格,模糊匹配 */
|
||||
keyword?: string
|
||||
product_category?: string
|
||||
category_missing?: boolean
|
||||
status?: number
|
||||
}
|
||||
|
||||
@@ -56,8 +57,8 @@ export const PRODUCT_CATEGORY_OPTIONS = [
|
||||
] as const
|
||||
|
||||
/** 类别编码 → 展示名(表格列展示用,未知编码原样回显) */
|
||||
export function productCategoryLabel(code: string): string {
|
||||
return PRODUCT_CATEGORY_OPTIONS.find((o) => o.value === code)?.label ?? code
|
||||
export function productCategoryLabel(code: string | null): string {
|
||||
return code ? PRODUCT_CATEGORY_OPTIONS.find((o) => o.value === code)?.label ?? code : '待确认'
|
||||
}
|
||||
|
||||
/** 分页查询产品(含停用) */
|
||||
@@ -65,6 +66,10 @@ export function listProducts(params: ProductQueryParams): Promise<PageData<Produ
|
||||
return request({ url: '/v1/base/products', method: 'get', params })
|
||||
}
|
||||
|
||||
export function batchProductCategory(ids: number[], category: string): Promise<{ updated: number }> {
|
||||
return request({ url: '/v1/base/products/batch-category', method: 'put', data: { ids, category } })
|
||||
}
|
||||
|
||||
/** 新增产品(默认启用;类别须在合法值域内) */
|
||||
export function createProduct(data: ProductSaveParams): Promise<ProductItem> {
|
||||
return request({ url: '/v1/base/products', method: 'post', data })
|
||||
|
||||
@@ -22,6 +22,10 @@ export interface ReceiptItem {
|
||||
is_barter: number
|
||||
/** 核算状态:0未核算 1已核算(已核算禁改禁删) */
|
||||
settlement_status: number
|
||||
confirmation_status: number
|
||||
confirmed_by: string | null
|
||||
confirmed_at: string | null
|
||||
confirmation_revision: string
|
||||
payment_days: number | null
|
||||
remark: string | null
|
||||
}
|
||||
@@ -36,6 +40,7 @@ export interface ReceiptQueryParams {
|
||||
date_to?: string
|
||||
is_barter?: number
|
||||
settlement_status?: number
|
||||
confirmation_status?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,6 +88,14 @@ export function listReceipts(params: ReceiptQueryParams): Promise<PageData<Recei
|
||||
return request({ url: '/v1/business/receipts', method: 'get', params })
|
||||
}
|
||||
|
||||
export function getConfirmationPolicy(): Promise<{ enabled: boolean }> {
|
||||
return request({ url: '/v1/business/receipts/confirmation-policy', method: 'get' })
|
||||
}
|
||||
|
||||
export function confirmReceipt(row: ReceiptItem): Promise<ReceiptItem> {
|
||||
return request({ url: `/v1/business/receipts/${row.id}/confirm`, method: 'post', data: { expected_revision: row.confirmation_revision } })
|
||||
}
|
||||
|
||||
/** 新增回款(批次号自动生成 HK 前缀;业务须执行中) */
|
||||
export function createReceipt(data: ReceiptCreateParams): Promise<ReceiptItem> {
|
||||
return request({ url: '/v1/business/receipts', method: 'post', data })
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface ReportSummary {
|
||||
ship_total?: string | null
|
||||
received_total?: string | null
|
||||
sale_total?: string | null
|
||||
sale_pending_count?: number
|
||||
}
|
||||
|
||||
export interface ReportPage<T> extends PageData<T> {
|
||||
|
||||
@@ -2,14 +2,45 @@ import service, { request } from './request'
|
||||
import type { PageData } from './types'
|
||||
|
||||
export type FactKind = 'PERSONNEL' | 'PRODUCT' | 'CUSTOMER' | 'GROUP' | 'GUIDANCE' | 'RECEIPT' | 'SHIPMENT' | 'HANDOVER' | 'MANAGER' | 'CORRECTION'
|
||||
export type WorkKind = 'MASTER_PREVIEW' | 'MASTER_APPLY' | 'CALC_PREVIEW' | 'BUSINESS_PREVIEW' | 'BUSINESS_APPLY'
|
||||
export type WorkKind = 'MASTER_PREVIEW' | 'MASTER_APPLY' | 'CALC_PREVIEW' | 'BUSINESS_PREVIEW' | 'BUSINESS_APPLY' | 'BUSINESS_SYNC'
|
||||
export interface RolePeriod { start_month: string; end_month: string; role_code: 'salesperson' | 'sales_manager' }
|
||||
export interface Fact { id: number; kind: FactKind; fact_key: string; version: number; data: Record<string, unknown>; evidence: string; requested_by: number; create_time: string }
|
||||
export interface WorkRun { run_no: string; kind: WorkKind; status: string; input: Record<string, unknown>; summary: Record<string, unknown> | null; snapshot_hash: string | null; error_code: string | null; started_at: string | null; finished_at: string | null; create_time: string; request_key: string; formal_payroll_written: false }
|
||||
export interface WorkLine { line_no: number; entity_type: string; source_id: string; action: string; reason: string | null; data: Record<string, unknown> }
|
||||
export interface SourceChoice { source_id: string; source_no: string | null; name: string; parent_id: string; date: string }
|
||||
const base = '/v1/system/tplus'
|
||||
export interface BusinessSyncConfig { enabled: boolean; start_date: string; version: number; editable: boolean; last_error: string | null }
|
||||
export interface BusinessSyncPreview {
|
||||
start_date: string
|
||||
documents: Record<string, number>
|
||||
candidates: Record<string, { new: number; existing: number }>
|
||||
existing_conflicts: number
|
||||
customer_scope: {
|
||||
candidate_customers: number
|
||||
without_business_link: number
|
||||
identity_warnings: number
|
||||
warning_counts: Record<string, number>
|
||||
examples: { source_id: string; reason: string }[]
|
||||
truncated: boolean
|
||||
note: string
|
||||
}
|
||||
product_scope: {
|
||||
candidate_products: number
|
||||
without_product_link: number
|
||||
identity_warnings: number
|
||||
warning_counts: Record<string, number>
|
||||
examples: { source_id: string; reason: string }[]
|
||||
truncated: boolean
|
||||
note: string
|
||||
}
|
||||
}
|
||||
export function businessSyncConfig(): Promise<BusinessSyncConfig> { return request({url: `${base}/business-sync`}) }
|
||||
export function saveBusinessSync(data: { enabled: boolean; start_date: string; expected_version: number }): Promise<BusinessSyncConfig> { return request({url: `${base}/business-sync`, method: 'put', data}) }
|
||||
export function previewBusinessSync(start_date: string): Promise<BusinessSyncPreview> { return request({url: `${base}/business-sync/preview`, method: 'post', data: {start_date}, timeout: 60000}) }
|
||||
export function runBusinessSync(start_date: string, request_key: string): Promise<WorkRun> { return request({url: `${base}/business-sync/runs`, method: 'post', data: {start_date, request_key}}) }
|
||||
export function listFacts(params: {kind?: string; pageNum?: number; pageSize?: number}): Promise<PageData<Fact>> { return request({url: `${base}/facts`, params}) }
|
||||
export function saveFact(data: {kind: FactKind; fact_key: string; expected_version: number; evidence: string; data: Record<string, unknown>}): Promise<Fact> { return request({url: `${base}/facts`, method: 'post', data}) }
|
||||
export function saveCorrectionPair(data: {negative_shipment_id: string; positive_shipment_id: string; receipt_ids: string[]; target_group_key: string; evidence: string}): Promise<{pair_key: string}> { return request({url: `${base}/correction-pairs`, method: 'post', data}) }
|
||||
export function factHistory(id: number): Promise<PageData<Fact>> { return request({url: `${base}/facts/${id}/history`, params: {pageSize: 200}}) }
|
||||
export function listWorks(params: {kind?: string; pageNum?: number; pageSize?: number}): Promise<PageData<WorkRun>> { return request({url: `${base}/work-runs`, params}) }
|
||||
export function createWork(data: {kind: WorkKind; request_key: string; entity_types?: string[]; source_ids?: string[]; preview_no?: string; replay_no?: string; month?: string; group_keys?: string[]}): Promise<WorkRun> { return request({url: `${base}/work-runs`, method: 'post', data}) }
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{ loading: boolean }>()
|
||||
defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-alert class="list-load-error" type="error" title="列表加载失败,请重试" :closable="false" show-icon>
|
||||
<p>若下方保留了记录,请以重新加载后的结果为准。</p>
|
||||
<el-button :loading="loading" @click="$emit('retry')">重新加载</el-button>
|
||||
</el-alert>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.list-load-error { margin-bottom: 14px; }
|
||||
.list-load-error p { margin: 0 0 10px; line-height: 1.7; }
|
||||
</style>
|
||||
@@ -24,6 +24,7 @@ export function usePagedList<T, F extends object, D extends PageData<T> = PageDa
|
||||
const pageNum = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const loading = ref(false)
|
||||
const loadFailed = ref(false)
|
||||
|
||||
/** 自增请求序号:仅最新请求的响应可写入状态 */
|
||||
let requestSeq = 0
|
||||
@@ -60,6 +61,7 @@ export function usePagedList<T, F extends object, D extends PageData<T> = PageDa
|
||||
const reqPageNum = pageNum.value
|
||||
const reqPageSize = pageSize.value
|
||||
loading.value = true
|
||||
loadFailed.value = false
|
||||
try {
|
||||
const data = await fetcher({
|
||||
...getFilters(),
|
||||
@@ -74,6 +76,7 @@ export function usePagedList<T, F extends object, D extends PageData<T> = PageDa
|
||||
onData?.(data)
|
||||
return { ok: true, seq }
|
||||
} catch (error) {
|
||||
if (seq === requestSeq) loadFailed.value = true
|
||||
handledError(error) // 失败提示由请求拦截器统一完成
|
||||
return { ok: false, seq }
|
||||
} finally {
|
||||
@@ -139,6 +142,7 @@ export function usePagedList<T, F extends object, D extends PageData<T> = PageDa
|
||||
pageNum,
|
||||
pageSize,
|
||||
loading,
|
||||
loadFailed,
|
||||
load,
|
||||
search,
|
||||
onPageChange,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||
import { optionPage, options } from '@/api/metadata'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
@@ -36,6 +37,33 @@ import RatingCalculatorDialog from './RatingCalculatorDialog.vue'
|
||||
* 当前等级映射实时算(utils/rating.ts 与弹窗共用同一判定函数)
|
||||
*/
|
||||
const { confirmAndRun } = useConfirmAction()
|
||||
const selectedCustomers = ref<CustomerItem[]>([])
|
||||
const batchCategory = ref('')
|
||||
const batchOwner = ref<number>()
|
||||
|
||||
async function fillCategories(): Promise<void> {
|
||||
const ids = selectedCustomers.value.map(item => item.id)
|
||||
const category = batchCategory.value
|
||||
if (!ids.length || !category) return
|
||||
await confirmAndRun({
|
||||
confirmText: `将 ${ids.length} 条待确认客户分类设为${customerApi.customerTypeLabel(category)}?`,
|
||||
successText: '客户分类已补充',
|
||||
run: () => customerApi.batchCustomerCategory(ids, category),
|
||||
after: async () => { selectedCustomers.value = []; batchCategory.value = ''; await load() },
|
||||
})
|
||||
}
|
||||
|
||||
async function fillOwnership(): Promise<void> {
|
||||
const ids = selectedCustomers.value.map(item => item.id)
|
||||
const owner = batchOwner.value
|
||||
if (!ids.length || !owner) return
|
||||
await confirmAndRun({
|
||||
confirmText: `将 ${ids.length} 条待归属客户分配给${personnelName(owner)}?仅影响后续核算,不回溯历史。`,
|
||||
successText: '客户归属已补充',
|
||||
run: () => customerApi.batchCustomerOwnership(ids, owner),
|
||||
after: async () => { selectedCustomers.value = []; batchOwner.value = undefined; await load() },
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 列表 ====================
|
||||
|
||||
@@ -43,15 +71,18 @@ const filters = reactive({
|
||||
keyword: '',
|
||||
customer_type: undefined as string | undefined,
|
||||
owner_personnel_id: undefined as number | undefined,
|
||||
owner_missing: false,
|
||||
})
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
usePagedList<CustomerItem, customerApi.CustomerQueryParams>(
|
||||
customerApi.listCustomers,
|
||||
() => ({
|
||||
keyword: filters.keyword,
|
||||
customer_type: filters.customer_type,
|
||||
owner_personnel_id: filters.owner_personnel_id,
|
||||
customer_type: filters.customer_type === '__missing__' ? undefined : filters.customer_type,
|
||||
category_missing: filters.customer_type === '__missing__',
|
||||
owner_personnel_id: filters.owner_missing ? undefined : filters.owner_personnel_id,
|
||||
owner_missing: filters.owner_missing,
|
||||
order_by: sortState.order_by,
|
||||
order: sortState.order,
|
||||
}),
|
||||
@@ -61,6 +92,7 @@ function resetFilters(): void {
|
||||
filters.keyword = ''
|
||||
filters.customer_type = undefined
|
||||
filters.owner_personnel_id = undefined
|
||||
filters.owner_missing = false
|
||||
search()
|
||||
}
|
||||
|
||||
@@ -244,7 +276,7 @@ function openEdit(row: CustomerItem): void {
|
||||
contact_phone: row.contact_phone ?? '',
|
||||
address: row.address ?? '',
|
||||
industry: row.industry ?? '',
|
||||
customer_type: row.customer_type,
|
||||
customer_type: row.customer_type ?? '',
|
||||
owner_personnel_id: undefined, // 编辑弹窗不含归属,归属走「归属变更」按钮
|
||||
remark: row.remark ?? '',
|
||||
})
|
||||
@@ -438,6 +470,7 @@ onMounted(() => {
|
||||
.filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器,
|
||||
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
||||
-->
|
||||
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
@@ -450,6 +483,7 @@ onMounted(() => {
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="filters.customer_type" placeholder="全部" clearable style="width: 130px">
|
||||
<el-option label="待确认" value="__missing__" />
|
||||
<el-option
|
||||
v-for="item in customerApi.CUSTOMER_TYPE_OPTIONS"
|
||||
:key="item.value"
|
||||
@@ -464,6 +498,7 @@ onMounted(() => {
|
||||
placeholder="全部"
|
||||
clearable
|
||||
filterable
|
||||
:disabled="filters.owner_missing"
|
||||
style="width: 160px"
|
||||
>
|
||||
<el-option
|
||||
@@ -474,6 +509,7 @@ onMounted(() => {
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item><el-checkbox v-model="filters.owner_missing">待归属</el-checkbox></el-form-item>
|
||||
<div class="filter-bar__actions">
|
||||
<el-button type="primary" @click="search">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
@@ -482,6 +518,14 @@ onMounted(() => {
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate" :disabled="!permissionUser.can('base:customers:create')" :title="permissionUser.permissionReason('base:customers:create')">新增客户</el-button>
|
||||
<el-select v-model="batchCategory" placeholder="选择批量分类" style="width: 150px" :disabled="!permissionUser.can('base:customers:update')">
|
||||
<el-option v-for="item in customerApi.CUSTOMER_TYPE_OPTIONS" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-button :disabled="!permissionUser.can('base:customers:update') || !selectedCustomers.length || !batchCategory || selectedCustomers.some(row => !!row.customer_type)" @click="fillCategories">批量补充分类({{ selectedCustomers.length }})</el-button>
|
||||
<el-select v-model="batchOwner" placeholder="选择归属人员" filterable style="width: 150px" :disabled="!permissionUser.can('base:customers:ownership')">
|
||||
<el-option v-for="item in ownerOptions" :key="item.id" :label="ownerLabel(item)" :value="item.id" />
|
||||
</el-select>
|
||||
<el-button :disabled="!permissionUser.can('base:customers:ownership') || !selectedCustomers.length || !batchOwner || selectedCustomers.some(row => row.owner_personnel_id != null)" @click="fillOwnership">批量补充归属({{ selectedCustomers.length }})</el-button>
|
||||
<span class="toolbar-note">归属调整走行内「归属变更」,系统自动留痕</span>
|
||||
</div>
|
||||
|
||||
@@ -491,10 +535,12 @@ onMounted(() => {
|
||||
v-loading="loading"
|
||||
:data="records"
|
||||
stripe
|
||||
empty-text="还没有客户 —— 点「新增客户」建第一个,或换个关键词再查"
|
||||
empty-text="暂无符合条件的客户,可调整筛选条件。"
|
||||
@sort-change="onSortChange"
|
||||
@selection-change="selectedCustomers = $event"
|
||||
>
|
||||
<el-table-column prop="company_name" label="公司名称" min-width="180" />
|
||||
<el-table-column type="selection" width="46" :selectable="(row: CustomerItem) => (!row.customer_type && permissionUser.can('base:customers:update')) || (row.owner_personnel_id == null && permissionUser.can('base:customers:ownership'))" />
|
||||
<el-table-column prop="company_name" label="公司名称" min-width="180" show-overflow-tooltip />
|
||||
<!--
|
||||
评级列 = 等级 + 分数,纯文字两级层次。
|
||||
等级不落库,按当前等级映射由已存分数实时算(与评级弹窗同一判定函数
|
||||
@@ -542,7 +588,9 @@ onMounted(() => {
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="
|
||||
row.customer_type === 'personal'
|
||||
!row.customer_type
|
||||
? 'warning'
|
||||
: row.customer_type === 'personal'
|
||||
? 'info'
|
||||
: row.customer_type === 'manager_personal'
|
||||
? 'warning'
|
||||
@@ -592,17 +640,23 @@ onMounted(() => {
|
||||
<!-- 新增 / 编辑(编辑不含归属,归属走独立按钮保证留痕) -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:close-on-click-modal="!submitting"
|
||||
:close-on-press-escape="!submitting"
|
||||
:show-close="!submitting"
|
||||
:title="isEdit ? '编辑客户' : '新增客户'"
|
||||
width="560px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :disabled="!permissionUser.can(isEdit ? 'base:customers:update' : 'base:customers:create')" :model="form" :rules="rules" label-width="110px">
|
||||
<h2 class="form-section-title">客户信息</h2>
|
||||
<el-form-item label="公司名称" prop="company_name">
|
||||
<el-input v-model="form.company_name" maxlength="128" placeholder="与负责人构成组合唯一键" />
|
||||
<el-input v-model="form.company_name" maxlength="128" placeholder="请输入客户公司名称" />
|
||||
<p class="form-hint">公司名称与负责人的组合不能重复。</p>
|
||||
</el-form-item>
|
||||
<el-form-item label="负责人" prop="owner_name">
|
||||
<el-input v-model="form.owner_name" maxlength="64" placeholder="客户公司的负责人姓名" />
|
||||
</el-form-item>
|
||||
<h2 class="form-section-title">联系方式</h2>
|
||||
<el-form-item label="联系人" prop="contact_person">
|
||||
<el-input v-model="form.contact_person" maxlength="64" placeholder="选填" />
|
||||
</el-form-item>
|
||||
@@ -612,6 +666,7 @@ onMounted(() => {
|
||||
<el-form-item label="地址" prop="address">
|
||||
<el-input v-model="form.address" maxlength="255" placeholder="选填" />
|
||||
</el-form-item>
|
||||
<h2 class="form-section-title">分类与归属</h2>
|
||||
<el-form-item label="行业" prop="industry">
|
||||
<el-input v-model="form.industry" maxlength="64" placeholder="选填" />
|
||||
</el-form-item>
|
||||
@@ -628,7 +683,7 @@ onMounted(() => {
|
||||
<el-form-item v-if="!isEdit" label="初始归属" prop="owner_personnel_id">
|
||||
<el-select
|
||||
v-model="form.owner_personnel_id"
|
||||
placeholder="不选择=暂无归属(可先建档后派归属)"
|
||||
placeholder="选填,可建档后再分配"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 100%"
|
||||
@@ -646,7 +701,7 @@ onMounted(() => {
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button :disabled="submitting" @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can((isEdit ? 'base:customers:update' : 'base:customers:create'))" :title="permissionUser.permissionReason((isEdit ? 'base:customers:update' : 'base:customers:create'))">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||
import ProductExtractDialog from '@/components/products/ProductExtractDialog.vue'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
@@ -29,6 +30,20 @@ import { handledError } from '@/utils/error'
|
||||
* - 编辑全量提交、不含状态;启用/停用与删除为独立操作
|
||||
*/
|
||||
const { confirmAndRun } = useConfirmAction()
|
||||
const selectedProducts = ref<ProductItem[]>([])
|
||||
const batchCategory = ref('')
|
||||
|
||||
async function fillCategories(): Promise<void> {
|
||||
const ids = selectedProducts.value.map(item => item.id)
|
||||
const category = batchCategory.value
|
||||
if (!ids.length || !category) return
|
||||
await confirmAndRun({
|
||||
confirmText: `将 ${ids.length} 条待确认产品分类设为${productApi.productCategoryLabel(category)}?`,
|
||||
successText: '产品分类已补充',
|
||||
run: () => productApi.batchProductCategory(ids, category),
|
||||
after: async () => { selectedProducts.value = []; batchCategory.value = ''; await load() },
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 列表 ====================
|
||||
|
||||
@@ -38,12 +53,13 @@ const filters = reactive({
|
||||
status: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
usePagedList<ProductItem, productApi.ProductQueryParams>(
|
||||
productApi.listProducts,
|
||||
() => ({
|
||||
keyword: filters.keyword,
|
||||
product_category: filters.product_category,
|
||||
product_category: filters.product_category === '__missing__' ? undefined : filters.product_category,
|
||||
category_missing: filters.product_category === '__missing__',
|
||||
status: filters.status,
|
||||
}),
|
||||
)
|
||||
@@ -115,7 +131,7 @@ function openEdit(row: ProductItem): void {
|
||||
unit: row.unit ?? '',
|
||||
stock_quantity: row.stock_quantity ?? '',
|
||||
sale_price: row.sale_price ?? '',
|
||||
product_category: row.product_category,
|
||||
product_category: row.product_category ?? '',
|
||||
remark: row.remark ?? '',
|
||||
})
|
||||
dialogVisible.value = true
|
||||
@@ -202,7 +218,7 @@ onMounted(() => {
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">产品管理</h1>
|
||||
<p class="page-desc">
|
||||
产品主数据;类别是折算阈值的行级锚点,改类别会改变该产品成交回款的绩效口径
|
||||
维护产品名称、规格与类别。产品类别决定回款绩效使用的折算阈值。
|
||||
</p>
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
@@ -219,6 +235,7 @@ onMounted(() => {
|
||||
.filter-bar 直接挂在 el-form 上:el-form 不是 flex 透明容器,
|
||||
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
||||
-->
|
||||
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
@@ -231,6 +248,7 @@ onMounted(() => {
|
||||
</el-form-item>
|
||||
<el-form-item label="类别">
|
||||
<el-select v-model="filters.product_category" placeholder="全部" clearable style="width: 130px">
|
||||
<el-option label="待确认" value="__missing__" />
|
||||
<el-option
|
||||
v-for="item in productApi.PRODUCT_CATEGORY_OPTIONS"
|
||||
:key="item.value"
|
||||
@@ -258,7 +276,11 @@ onMounted(() => {
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate" :disabled="!permissionUser.can('base:products:create')" :title="permissionUser.permissionReason('base:products:create')">新增产品</el-button>
|
||||
<el-button :disabled="!permissionUser.can('base:products:create')" @click="extractDialog?.open()">从业务提取产品</el-button>
|
||||
<span class="toolbar-note">常规下架用「停用」,删除是物理删除、不可恢复</span>
|
||||
<el-select v-model="batchCategory" placeholder="选择批量分类" style="width: 150px" :disabled="!permissionUser.can('base:products:update')">
|
||||
<el-option v-for="item in productApi.PRODUCT_CATEGORY_OPTIONS" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
<el-button :disabled="!permissionUser.can('base:products:update') || !selectedProducts.length || !batchCategory" @click="fillCategories">批量补充分类({{ selectedProducts.length }})</el-button>
|
||||
<span class="toolbar-note">不再使用的产品可停用;删除后无法恢复。</span>
|
||||
</div>
|
||||
|
||||
<!-- ys-rise:整页只给列表卡一次入场上浮,筛选与页头立即呈现,避免整屏一起动 -->
|
||||
@@ -267,23 +289,25 @@ onMounted(() => {
|
||||
v-loading="loading"
|
||||
:data="records"
|
||||
stripe
|
||||
empty-text="还没有产品 —— 点「新增产品」建第一个,或换个关键词再查"
|
||||
empty-text="暂无符合条件的产品,可调整筛选条件。"
|
||||
@selection-change="selectedProducts = $event"
|
||||
>
|
||||
<el-table-column prop="product_name" label="产品名称" min-width="150" />
|
||||
<el-table-column prop="spec_model" label="规格型号" min-width="140">
|
||||
<el-table-column type="selection" width="46" :selectable="(row: ProductItem) => !row.product_category && permissionUser.can('base:products:update')" />
|
||||
<el-table-column prop="product_name" label="产品名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="spec_model" label="规格型号" min-width="140" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.spec_model || '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit" label="单位" width="80" align="center">
|
||||
<template #default="{ row }">{{ row.unit ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源/人工保护" min-width="205"><template #default="{row}"><div>{{ row.source_trade_json?.date ?? '人工维护' }}</div><div v-if="row.source_trade_json?.source_no" class="cell-muted">{{ row.source_trade_json.source_no }}</div><div v-else class="cell-muted">暂无有效发货来源</div><el-tag v-if="row.quantity_price_manual" type="warning" size="small">人工值保护</el-tag><div v-if="row.quantity_price_manual && row.source_trade_json">源值:{{row.source_trade_json.quantity}} / {{row.source_trade_json.price}}<el-button link type="primary" :disabled="!permissionUser.can('base:products:update')" @click="restoreSource(row)">恢复源值</el-button></div></template></el-table-column>
|
||||
<el-table-column label="最近发货数量" min-width="100" align="right">
|
||||
<el-table-column label="最近发货数量" min-width="130" align="right">
|
||||
<template #default="{ row }">
|
||||
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
|
||||
<span class="cell-num">{{ row.stock_quantity ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近含税成交价(元)" min-width="110" align="right">
|
||||
<el-table-column label="最近含税成交价(元)" min-width="165" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-num">{{ row.sale_price ?? '—' }}</span>
|
||||
</template>
|
||||
@@ -291,7 +315,7 @@ onMounted(() => {
|
||||
<el-table-column label="类别" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="row.product_category === 'municipal_water' ? 'primary' : 'success'"
|
||||
:type="!row.product_category ? 'warning' : row.product_category === 'municipal_water' ? 'primary' : 'success'"
|
||||
size="small"
|
||||
disable-transitions
|
||||
>
|
||||
@@ -304,7 +328,7 @@ onMounted(() => {
|
||||
<StatusTag :value="row.status" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="170" fixed="right" align="center">
|
||||
<el-table-column label="操作" width="230" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">详情</el-button>
|
||||
@@ -333,13 +357,18 @@ onMounted(() => {
|
||||
<!-- 新增 / 编辑 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:close-on-click-modal="!submitting"
|
||||
:close-on-press-escape="!submitting"
|
||||
:show-close="!submitting"
|
||||
:title="isEdit ? '编辑产品' : '新增产品'"
|
||||
width="620px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" class="record-form" :disabled="!permissionUser.can(isEdit ? 'base:products:update' : 'base:products:create')" :model="form" :rules="rules" label-width="156px">
|
||||
<h2 class="form-section-title">产品信息</h2>
|
||||
<el-form-item label="产品名称" prop="product_name">
|
||||
<el-input v-model="form.product_name" maxlength="128" placeholder="与规格构成组合唯一键" />
|
||||
<el-input v-model="form.product_name" maxlength="128" placeholder="请输入产品名称" />
|
||||
<p class="form-hint">产品名称与规格型号的组合不能重复。</p>
|
||||
</el-form-item>
|
||||
<el-form-item label="规格型号" prop="spec_model">
|
||||
<el-input v-model="form.spec_model" maxlength="128" placeholder="选填" />
|
||||
@@ -347,12 +376,14 @@ onMounted(() => {
|
||||
<el-form-item label="计量单位" prop="unit">
|
||||
<el-input v-model="form.unit" maxlength="16" placeholder="如 台 / 套 / 米" />
|
||||
</el-form-item>
|
||||
<h2 class="form-section-title">发货与成交信息</h2>
|
||||
<el-form-item label="最近发货数量" prop="stock_quantity">
|
||||
<el-input v-model="form.stock_quantity" placeholder="非负数字,选填" />
|
||||
</el-form-item>
|
||||
<el-form-item label="最近含税成交价(元)" prop="sale_price">
|
||||
<el-input v-model="form.sale_price" placeholder="非负数字,选填" />
|
||||
</el-form-item>
|
||||
<h2 class="form-section-title">分类与备注</h2>
|
||||
<el-form-item label="产品类别" prop="product_category">
|
||||
<el-select
|
||||
v-model="form.product_category"
|
||||
@@ -372,7 +403,7 @@ onMounted(() => {
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button :disabled="submitting" @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can((isEdit ? 'base:products:update' : 'base:products:create'))" :title="permissionUser.permissionReason((isEdit ? 'base:products:update' : 'base:products:create'))">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -2,41 +2,79 @@
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Delete, Plus } from '@element-plus/icons-vue'
|
||||
import * as payroll from '@/api/payroll'
|
||||
import { request } from '@/api/request'
|
||||
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
const open = defineModel<boolean>({required: true})
|
||||
const props = defineProps<{businessId: number}>()
|
||||
const props = defineProps<{businessId: number; sourceManaged: boolean}>()
|
||||
const month = ref(''), opening = ref(''), evidence = ref(''), confirmed = ref(false)
|
||||
const busy = ref(false), loading = ref(false), failed = ref(false), locked = ref(true)
|
||||
const monthChecked = ref(false), monthLoading = ref(false)
|
||||
let monthRequest = 0
|
||||
const input = ref<payroll.PayrollInput | null>(null), result = ref<payroll.CalculationResult | null>(null)
|
||||
const people = ref<{id: number; personnel_name: string}[]>([])
|
||||
const rows = ref<(payroll.Ownership & {name: string; ship_date: string; amount: string})[]>([])
|
||||
const rows = ref<(payroll.Ownership & {name: string; ship_date: string; amount: string; source_personnel_name?: string | null; matched_personnel_id?: number | null; guidance_status?: string; guidance_line_total?: string | null; frozen?: boolean})[]>([])
|
||||
const rowPage = ref(1)
|
||||
const businessKey = ref('')
|
||||
const handovers = ref<payroll.Handover[]>([])
|
||||
const savedHandovers = ref('[]')
|
||||
const handoversChanged = computed(() => JSON.stringify(handovers.value) !== savedHandovers.value)
|
||||
const visibleRows = computed(() => rows.value.slice((rowPage.value - 1) * 50, rowPage.value * 50))
|
||||
|
||||
type PayrollMetadata = { business_key?: string | null; personnel: typeof people.value; personnel_total?: number; shipments_total?: number;
|
||||
shipments: {id: number; shipment_no: string; product_name: string; ship_date: string; amount: string; source_personnel_name?: string | null; matched_personnel_id?: number | null; guidance_status?: string; guidance_unit_cost?: string | null; guidance_line_total?: string | null}[] }
|
||||
|
||||
function guidanceHint(row: {guidance_status?: string; guidance_line_total?: string | null; frozen?: boolean}): string {
|
||||
const labels: Record<string, string> = {FINALIZED_UNIT: row.frozen ? '指导价已封存,历史成本以原版本为准' : '使用已封存的月度指导单价', FINALIZED_LINE_TOTAL: `已封存明细总成本:${row.guidance_line_total ?? '待核对'} 元`, AMBIGUOUS: '存在多条指导成本,请先核对', UNIT_MISMATCH: '指导成本单位不一致,请先核对', UNFINALIZED: '指导成本尚未封存'}
|
||||
return labels[row.guidance_status ?? ''] ?? ''
|
||||
}
|
||||
|
||||
async function loadMetadata(): Promise<PayrollMetadata> {
|
||||
const pageSize = 200
|
||||
const getPage = (pageNum: number) => request<PayrollMetadata>({url: `/v1/meta/payroll-input/${props.businessId}`, params: {pageNum, pageSize}})
|
||||
const first = await getPage(1)
|
||||
const personnelTotal = first.personnel_total ?? first.personnel.length
|
||||
const shipmentsTotal = first.shipments_total ?? first.shipments.length
|
||||
const personnel = [...first.personnel], shipments = [...first.shipments]
|
||||
for (let page = 2; page <= Math.ceil(Math.max(personnelTotal, shipmentsTotal) / pageSize); page++) {
|
||||
const next = await getPage(page)
|
||||
if (next.personnel_total !== personnelTotal || next.shipments_total !== shipmentsTotal || next.business_key !== first.business_key) throw new Error('核算参照数据已变化,请重新加载')
|
||||
personnel.push(...next.personnel); shipments.push(...next.shipments)
|
||||
}
|
||||
if (personnel.length !== personnelTotal || shipments.length !== shipmentsTotal ||
|
||||
new Set(personnel.map(p => p.id)).size !== personnelTotal || new Set(shipments.map(s => s.id)).size !== shipmentsTotal) {
|
||||
throw new Error('核算参照数据不完整,请重新加载')
|
||||
}
|
||||
return { business_key: first.business_key, personnel, shipments }
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true; failed.value = false; result.value = null
|
||||
try {
|
||||
input.value = await payroll.getInput(props.businessId)
|
||||
rows.value = []
|
||||
if (!input.value || input.value.source === 'MANUAL') {
|
||||
const data = await request<{personnel: typeof people.value; shipments: {id: number; shipment_no: string; product_name: string; ship_date: string; amount: string}[]}>({url: `/v1/meta/payroll-input/${props.businessId}`})
|
||||
rows.value = []; handovers.value = []; businessKey.value = ''
|
||||
if (!input.value || input.value.source === 'MANUAL' || props.sourceManaged) {
|
||||
const data = await loadMetadata()
|
||||
people.value = data.personnel
|
||||
rows.value = data.shipments.map(s => ({shipment_item_id: s.id, name: `${s.shipment_no} · ${s.product_name}`, ship_date: s.ship_date, amount: s.amount, unit_cost: ''}))
|
||||
businessKey.value = data.business_key ?? ''
|
||||
rows.value = data.shipments.map(s => ({shipment_item_id: s.id, name: `${s.shipment_no} · ${s.product_name}`, ship_date: s.ship_date, amount: s.amount, source_personnel_name: s.source_personnel_name, matched_personnel_id: s.matched_personnel_id, personnel_id: s.matched_personnel_id ?? undefined, guidance_status: s.guidance_status, guidance_line_total: s.guidance_line_total, unit_cost: s.guidance_unit_cost ?? '', freight_amount: ''}))
|
||||
rowPage.value = 1
|
||||
if (input.value) {
|
||||
const data = input.value.data as {group?: {opening_balance?: string}; shipments?: {line_id: string; personnel_id: string; manager_id: string; cost_total: string; quantity: string; unit_cost?: string}[]; evidence?: string}
|
||||
const data = input.value.data as {group?: {opening_balance?: string}; facts?: {handovers?: payroll.Handover[]}; shipments?: {line_id: string; personnel_id: string; manager_id: string | null; cost_total: string; quantity: string; unit_cost?: string; freight_amount?: string}[]; evidence?: string}
|
||||
opening.value = data.group?.opening_balance ?? ''
|
||||
evidence.value = data.evidence ?? ''
|
||||
handovers.value = (data.facts?.handovers ?? []).map(handover => ({...handover}))
|
||||
for (const row of rows.value) {
|
||||
const previous = data.shipments?.find(s => s.line_id === String(row.shipment_item_id))
|
||||
if (previous) { row.personnel_id = Number(previous.personnel_id); row.manager_id = Number(previous.manager_id); row.unit_cost = previous.unit_cost ?? '' }
|
||||
if (previous) { row.personnel_id = Number(previous.personnel_id); row.manager_id = previous.manager_id ? Number(previous.manager_id) : undefined; row.unit_cost = previous.unit_cost ?? ''; row.freight_amount = previous.freight_amount ?? ''; row.frozen = props.sourceManaged }
|
||||
}
|
||||
}
|
||||
savedHandovers.value = JSON.stringify(handovers.value)
|
||||
}
|
||||
if (month.value) await checkMonth()
|
||||
} catch (e) { failed.value = true; handledError(e) } finally { loading.value = false }
|
||||
@@ -56,18 +94,27 @@ async function checkMonth(): Promise<void> {
|
||||
finally { if (request === monthRequest) monthLoading.value = false }
|
||||
}
|
||||
async function save(): Promise<void> {
|
||||
if (!month.value || !confirmed.value || !opening.value || !evidence.value.trim() || rows.value.some(r => !r.personnel_id || !r.manager_id || !r.unit_cost)) {
|
||||
ElMessage.warning('请填写月份、期初、依据及每条发货的人员、经理、指导成本,并核实历史承接'); return
|
||||
if (!month.value || !confirmed.value || !opening.value || !evidence.value.trim() || !rows.value.length || rows.value.some(r => !r.personnel_id || (!props.sourceManaged && !r.manager_id) || !r.unit_cost || (props.sourceManaged && !r.freight_amount))) {
|
||||
ElMessage.warning('请填写月份、期初、依据及每条发货的归属、指导成本和已核实的运费,并核实历史承接'); return
|
||||
}
|
||||
if (props.sourceManaged && handoversChanged.value && (!businessKey.value || handovers.value.some(h =>
|
||||
!h.from_personnel_id || !h.to_personnel_id || h.from_personnel_id === h.to_personnel_id || !h.effective_date ||
|
||||
(h.mode === 'ACTIVE' && !h.shipping_ownership_confirmed)))) {
|
||||
ElMessage.warning('请完整核实交接人员、日期和发货归属'); return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
await payroll.saveInput(props.businessId, {month: month.value, expected_version: input.value?.version ?? 0,
|
||||
evidence: evidence.value.trim(), opening_balance: opening.value, history_confirmed: true, guidance_finalized: true,
|
||||
shipment_ownerships: rows.value.map(({shipment_item_id, personnel_id, manager_id, unit_cost}) => ({shipment_item_id, personnel_id, manager_id, unit_cost}))})
|
||||
input.value = await payroll.getInput(props.businessId)
|
||||
shipment_ownerships: rows.value.map(({shipment_item_id, personnel_id, manager_id, unit_cost, freight_amount}) => ({shipment_item_id, personnel_id, manager_id, unit_cost, ...(props.sourceManaged ? {freight_amount} : {})})),
|
||||
...(props.sourceManaged && handoversChanged.value ? {handovers: handovers.value} : {})})
|
||||
await load()
|
||||
ElMessage.success('核算资料已保存为新版本')
|
||||
} catch (e) { handledError(e) } finally { busy.value = false }
|
||||
}
|
||||
function addHandover(): void {
|
||||
handovers.value.push({business_key: businessKey.value, mode: 'RESIGNATION', from_personnel_id: '', to_personnel_id: '', effective_date: '', shipping_ownership_confirmed: false})
|
||||
}
|
||||
async function run(): Promise<void> {
|
||||
if (!month.value) { ElMessage.warning('请选择核算月份'); return }
|
||||
try { await ElMessageBox.confirm('将按所选月份生成正式绩效台账。请先核对期初应收、发货归属和指导成本;本操作不会直接生成月度工资。', '确认生成正式台账', {confirmButtonText:'生成正式台账', cancelButtonText:'返回核对', customClass:'ys-confirm-box'}) } catch { return }
|
||||
@@ -79,7 +126,8 @@ watch(open, value => { monthRequest++; if (value) { month.value = ''; opening.va
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="open" title="正式绩效核算" width="min(1100px, 96vw)">
|
||||
<el-dialog v-model="open" title="正式绩效核算" width="min(1100px, 96vw)"
|
||||
:close-on-click-modal="!busy" :close-on-press-escape="!busy" :show-close="!busy">
|
||||
<div v-loading="loading">
|
||||
<p class="form-hint">本页生成绩效台账;月度工资需在“月度结算”中另行生成。</p>
|
||||
<p v-if="!permissionUser.can('payroll:calculate')" class="form-hint">当前账号可查看核算资料;没有正式核算权限,请联系管理员分配相应角色。</p>
|
||||
@@ -90,21 +138,55 @@ watch(open, value => { monthRequest++; if (value) { month.value = ''; opening.va
|
||||
<el-alert v-else-if="month && monthChecked && locked" title="该月工资已生成,分配方案已锁定;差额请通过当月调整单处理。" type="warning" :closable="false" />
|
||||
<el-alert v-else-if="month && !monthChecked" title="无法确认该月工资状态,请重新检查后再操作。" type="error" :closable="false"><el-button link @click="checkMonth">重新检查</el-button></el-alert>
|
||||
<p>资料版本:{{ input?.version ?? '尚未准备' }};来源:{{ input?.source === 'MANUAL' ? '人工核实' : input ? '源单据导入' : '—' }}</p>
|
||||
<template v-if="!failed && (!input || input.source === 'MANUAL')">
|
||||
<template v-if="!failed && (!input || input.source === 'MANUAL' || sourceManaged)">
|
||||
<el-form-item label="2026 年 2 月末期初应收(元,核实为零时填写 0)"><el-input v-model="opening" :disabled="!permissionUser.can('payroll:prepare') || locked" /></el-form-item>
|
||||
<el-form-item label="核对依据及修改原因"><el-input v-model="evidence" :disabled="!permissionUser.can('payroll:prepare') || locked" maxlength="500" /></el-form-item>
|
||||
<el-table :data="rows" empty-text="请先登记发货及产品明细">
|
||||
<el-table-column prop="name" label="发货明细" min-width="190" /><el-table-column prop="amount" label="不含运费销售额" width="130" />
|
||||
<el-table-column label="对应业务员" min-width="160"><template #default="{row}"><el-select v-model="row.personnel_id" filterable :disabled="!permissionUser.can('payroll:prepare') || locked" aria-label="发货归属人员"><el-option v-for="p in people" :key="p.id" :value="p.id" :label="p.personnel_name" /></el-select></template></el-table-column>
|
||||
<el-table-column label="当时分管经理" min-width="160"><template #default="{row}"><el-select v-model="row.manager_id" filterable :disabled="!permissionUser.can('payroll:prepare') || locked" aria-label="发货时分管经理"><el-option v-for="p in people" :key="p.id" :value="p.id" :label="p.personnel_name" /></el-select></template></el-table-column>
|
||||
<el-table-column label="含税单位指导成本" min-width="150"><template #default="{row}"><el-input v-model="row.unit_cost" :disabled="!permissionUser.can('payroll:prepare') || locked" aria-label="含税单位指导成本" /></template></el-table-column>
|
||||
<el-table :data="visibleRows" empty-text="请先登记发货及产品明细">
|
||||
<el-table-column prop="name" label="发货明细" min-width="190" /><el-table-column prop="amount" :label="sourceManaged ? '来源含税金额' : '不含运费销售额'" width="130" align="right" />
|
||||
<el-table-column v-if="sourceManaged" label="来源经办人" min-width="140"><template #default="{row}">{{ row.source_personnel_name || '待确认' }}{{ row.source_personnel_name && !row.matched_personnel_id ? '(待匹配)' : '' }}</template></el-table-column>
|
||||
<el-table-column label="对应业务员" min-width="160"><template #default="{row}"><el-select v-model="row.personnel_id" filterable :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen" aria-label="发货归属人员"><el-option v-for="p in people" :key="p.id" :value="p.id" :label="p.personnel_name" /></el-select></template></el-table-column>
|
||||
<el-table-column label="当时分管经理" min-width="160"><template #default="{row}"><el-select v-model="row.manager_id" filterable :clearable="sourceManaged" :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen" aria-label="发货时分管经理"><el-option v-for="p in people" :key="p.id" :value="p.id" :label="p.personnel_name" /></el-select></template></el-table-column>
|
||||
<el-table-column label="含税单位指导成本" min-width="170"><template #default="{row}"><el-input v-model="row.unit_cost" :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen || row.guidance_status === 'FINALIZED_UNIT'" aria-label="含税单位指导成本" /><small v-if="guidanceHint(row)">{{ guidanceHint(row) }}</small></template></el-table-column>
|
||||
<el-table-column v-if="sourceManaged" label="运费(已核实无运费填 0)" min-width="170"><template #default="{row}"><el-input v-model="row.freight_amount" :disabled="!permissionUser.can('payroll:prepare') || locked || row.frozen" aria-label="发货明细运费" /></template></el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-if="rows.length > 50" v-model:current-page="rowPage" :page-size="50" :total="rows.length" layout="prev, pager, next, total" />
|
||||
<section v-if="sourceManaged" class="handover-section" aria-label="业务交接">
|
||||
<div class="handover-heading">
|
||||
<h3>业务交接</h3>
|
||||
<el-button :icon="Plus" :disabled="!permissionUser.can('payroll:prepare') || locked || !businessKey" @click="addHandover">添加交接</el-button>
|
||||
</div>
|
||||
<el-alert v-if="!businessKey" title="当前服务未提供业务交接参照,暂不可修改交接资料。" type="warning" :closable="false" />
|
||||
<p v-if="!handovers.length" class="form-hint">暂无交接记录</p>
|
||||
<div v-for="(handover, index) in handovers" :key="index" class="handover-row">
|
||||
<el-form-item label="交接类型"><el-select v-model="handover.mode" :disabled="!permissionUser.can('payroll:prepare') || locked || !businessKey" aria-label="交接类型"><el-option label="离职交接" value="RESIGNATION" /><el-option label="在职交接" value="ACTIVE" /></el-select></el-form-item>
|
||||
<el-form-item label="移交人员"><el-select v-model="handover.from_personnel_id" filterable :disabled="!permissionUser.can('payroll:prepare') || locked || !businessKey" aria-label="移交人员"><el-option v-for="p in people" :key="p.id" :value="String(p.id)" :label="p.personnel_name" /></el-select></el-form-item>
|
||||
<el-form-item label="接手人员"><el-select v-model="handover.to_personnel_id" filterable :disabled="!permissionUser.can('payroll:prepare') || locked || !businessKey" aria-label="接手人员"><el-option v-for="p in people" :key="p.id" :value="String(p.id)" :label="p.personnel_name" /></el-select></el-form-item>
|
||||
<el-form-item label="生效日期"><el-date-picker v-model="handover.effective_date" type="date" value-format="YYYY-MM-DD" :disabled="!permissionUser.can('payroll:prepare') || locked || !businessKey" aria-label="交接生效日期" /></el-form-item>
|
||||
<el-tooltip content="移除交接" placement="top"><el-button :icon="Delete" circle :disabled="!permissionUser.can('payroll:prepare') || locked || !businessKey" aria-label="移除交接" @click="handovers.splice(index, 1)" /></el-tooltip>
|
||||
<el-checkbox v-if="handover.mode === 'ACTIVE'" v-model="handover.shipping_ownership_confirmed" class="handover-confirm" :disabled="!permissionUser.can('payroll:prepare') || locked || !businessKey">已逐次核实发货归属</el-checkbox>
|
||||
</div>
|
||||
</section>
|
||||
<p><el-checkbox v-model="confirmed" :disabled="!permissionUser.can('payroll:prepare') || locked">已核实期初与历史处理范围,发货月份指导成本录入完毕并封存</el-checkbox></p>
|
||||
<el-button :disabled="!permissionUser.can('payroll:prepare') || locked || failed" :loading="busy" @click="save" :title="permissionUser.permissionReason('payroll:prepare')">保存核算资料</el-button>
|
||||
</template>
|
||||
<el-button type="primary" :disabled="!permissionUser.can('payroll:calculate') || locked || failed || !input" :loading="busy" @click="run" :title="permissionUser.permissionReason('payroll:calculate')">生成正式台账</el-button>
|
||||
</el-form>
|
||||
<el-alert v-if="result" :title="`计提 ${result.performance} 元,本期应付 ${result.payable} 元;${result.start_payment_ok ? '已达到起付门槛' : '尚未达到起付门槛'}`" type="success" :closable="false" />
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button :disabled="busy" @click="open=false">关闭</el-button>
|
||||
<el-button type="primary" :disabled="!permissionUser.can('payroll:calculate') || locked || failed || !input" :loading="busy" @click="run" :title="permissionUser.permissionReason('payroll:calculate')">生成正式台账</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.handover-section { margin-top: 20px; border-top: 1px solid var(--el-border-color-light); }
|
||||
.handover-heading { display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||
.handover-heading h3 { font-size: 15px; }
|
||||
.handover-row { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)) auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--el-border-color-light); }
|
||||
.handover-row .el-date-editor { width: 100%; }
|
||||
.handover-confirm { grid-column: 1 / -1; margin-bottom: 12px; }
|
||||
@media (max-width: 760px) {
|
||||
.handover-row { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import * as api from '@/api/businesses'
|
||||
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
const props = defineProps<{ businessId: number }>()
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const user = useUserStore()
|
||||
const rows = ref<api.SalesRelationCandidate[]>([])
|
||||
const page = ref(1)
|
||||
const shipmentPage = ref(1)
|
||||
const orderPage = ref(1)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
const saving = ref<number | null>(null)
|
||||
const selections = ref<Record<number, { shipment?: number; order?: number; disposition?: 'ORDER' | 'INDEPENDENT'; evidence?: string }>>({})
|
||||
const shipmentCache = ref<api.SalesRelationCandidate['shipments']>([])
|
||||
const orderCache = ref<api.SalesRelationCandidate['orders']>([])
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
try {
|
||||
const result = await api.salesRelationCandidates(props.businessId, { pageNum: page.value, pageSize: 1, shipment_page: shipmentPage.value, order_page: orderPage.value })
|
||||
rows.value = result.list
|
||||
total.value = result.total
|
||||
for (const row of rows.value) {
|
||||
selections.value[row.item_id] ??= {}
|
||||
for (const item of row.shipments) {
|
||||
shipmentCache.value = [...shipmentCache.value.filter(old => old.document_id !== item.document_id), item]
|
||||
}
|
||||
for (const item of row.orders) {
|
||||
orderCache.value = [...orderCache.value.filter(old => old.document_id !== item.document_id), item]
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
rows.value = []
|
||||
failed.value = true
|
||||
handledError(error)
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function confirm(row: api.SalesRelationCandidate): Promise<void> {
|
||||
const value = selections.value[row.item_id]
|
||||
const shipment = shipmentCache.value.find(item => item.document_id === value.shipment)
|
||||
const order = orderCache.value.find(item => item.document_id === value.order)
|
||||
if (!shipment || !value.disposition || (value.disposition === 'ORDER' && !order) || !value.evidence?.trim()) {
|
||||
ElMessage.warning('请选择发货、关系及相应订单,并填写核对依据')
|
||||
return
|
||||
}
|
||||
saving.value = row.item_id
|
||||
try {
|
||||
await api.confirmSalesRelation(props.businessId, {
|
||||
item_id: row.item_id, shipment_document_id: shipment.document_id,
|
||||
order_document_id: value.disposition === 'ORDER' ? order!.document_id : null,
|
||||
disposition: value.disposition, item_revision: row.item_revision, shipment_revision: shipment.revision,
|
||||
order_revision: value.disposition === 'ORDER' ? order!.revision : null,
|
||||
expected_version: row.version, evidence: value.evidence.trim(),
|
||||
})
|
||||
ElMessage.success('核对结论已保存')
|
||||
await load()
|
||||
emit('changed')
|
||||
} catch (error) { handledError(error) }
|
||||
finally { saving.value = null }
|
||||
}
|
||||
|
||||
async function release(row: api.SalesRelationCandidate): Promise<void> {
|
||||
let evidence: string
|
||||
try {
|
||||
const result = await ElMessageBox.prompt('撤销后销售额将重新标记为待核对,已有核算快照不变。', '撤销核对', {
|
||||
inputPlaceholder: '填写撤销依据', inputValidator: value => !!value?.trim() || '请填写撤销依据',
|
||||
confirmButtonText: '撤销核对', cancelButtonText: '取消',
|
||||
})
|
||||
evidence = result.value.trim()
|
||||
} catch { return }
|
||||
saving.value = row.item_id
|
||||
try {
|
||||
await api.releaseSalesRelation(props.businessId, { item_id: row.item_id, expected_version: row.version, evidence })
|
||||
ElMessage.success('核对结论已撤销')
|
||||
await load()
|
||||
emit('changed')
|
||||
} catch (error) { handledError(error) }
|
||||
finally { saving.value = null }
|
||||
}
|
||||
|
||||
function changePage(value: number): void {
|
||||
page.value = value; shipmentPage.value = 1; orderPage.value = 1
|
||||
shipmentCache.value = []; orderCache.value = []; selections.value = {}
|
||||
void load()
|
||||
}
|
||||
function changeCandidates(kind: 'shipment' | 'order', delta: number): void {
|
||||
if (kind === 'shipment') shipmentPage.value += delta
|
||||
else orderPage.value += delta
|
||||
void load()
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="relations">
|
||||
<h3>订单与发货核对</h3>
|
||||
<ListLoadError v-if="failed" :loading="loading" @retry="load" />
|
||||
<template v-else>
|
||||
<p v-if="!rows.length">暂无可核对明细。</p>
|
||||
<div v-for="row in rows" :key="row.item_id" class="relation-row">
|
||||
<div><strong>{{ row.product_name }} {{ row.spec }}</strong>,数量 {{ row.quantity }},金额 {{ row.amount }}</div>
|
||||
<p v-if="row.confirmation">{{ row.confirmation.disposition === 'RELEASED' ? '已撤销' : row.confirmation.current ? '已确认' : '原确认已失效' }}:{{ row.confirmation.disposition === 'ORDER' ? '订单发货' : row.confirmation.disposition === 'INDEPENDENT' ? '独立发货' : '待重新核对' }};依据:{{ row.confirmation.evidence }}</p>
|
||||
<p v-if="row.confirmation?.shipment">发货 {{ row.confirmation.shipment.source_no || '无单号' }},明细 {{ row.confirmation.shipment.source_id }}<template v-if="row.confirmation.order">;订单 {{ row.confirmation.order.source_no || '无单号' }},明细 {{ row.confirmation.order.source_id }}</template></p>
|
||||
<el-form label-position="top" class="relation-form">
|
||||
<el-form-item label="来源发货"><el-select v-model="selections[row.item_id].shipment" placeholder="请选择" filterable>
|
||||
<el-option v-for="ship in shipmentCache" :key="ship.document_id" :value="ship.document_id" :label="`${ship.source_no || ship.source_id} / ${ship.date} / 明细 ${ship.source_id}`" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item label="关系"><el-select v-model="selections[row.item_id].disposition" placeholder="请选择">
|
||||
<el-option label="对应订单发货" value="ORDER" /><el-option label="独立发货" value="INDEPENDENT" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item v-if="selections[row.item_id].disposition === 'ORDER'" label="来源订单明细"><el-select v-model="selections[row.item_id].order" placeholder="请选择" filterable>
|
||||
<el-option v-for="order in orderCache" :key="order.document_id" :value="order.document_id" :label="`明细 ${order.source_id} / 数量 ${order.quantity} / 金额 ${order.amount}`" />
|
||||
</el-select></el-form-item>
|
||||
<el-form-item label="核对依据"><el-input v-model="selections[row.item_id].evidence" maxlength="500" placeholder="填写依据" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" :disabled="!user.can('business:businesses:update') || !user.can('payroll:prepare')" :loading="saving === row.item_id" @click="confirm(row)">保存核对</el-button></el-form-item>
|
||||
<el-form-item v-if="row.confirmation && row.confirmation.disposition !== 'RELEASED'"><el-button :disabled="!user.can('business:businesses:update') || !user.can('payroll:prepare')" :loading="saving === row.item_id" @click="release(row)">撤销核对</el-button></el-form-item>
|
||||
</el-form>
|
||||
<div>发货候选第 {{ shipmentPage }} 页
|
||||
<el-button link :disabled="shipmentPage === 1" @click="changeCandidates('shipment', -1)">上一页</el-button>
|
||||
<el-button link :disabled="!row.shipments_truncated" @click="changeCandidates('shipment', 1)">下一页</el-button>
|
||||
订单候选第 {{ orderPage }} 页
|
||||
<el-button link :disabled="orderPage === 1" @click="changeCandidates('order', -1)">上一页</el-button>
|
||||
<el-button link :disabled="!row.orders_truncated" @click="changeCandidates('order', 1)">下一页</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-pagination v-if="total > 1" layout="prev, pager, next, total" :current-page="page" :page-size="1" :total="total" @current-change="changePage" />
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.relations h3 { margin: 20px 0 12px; font-size: 15px; }
|
||||
.relation-row { padding: 12px 0; border-bottom: 1px solid var(--el-border-color-light); }
|
||||
.relation-form { display: flex; flex-wrap: wrap; align-items: end; gap: 0 12px; }
|
||||
.relation-form .el-select, .relation-form .el-input { width: 220px; max-width: 100%; }
|
||||
</style>
|
||||
@@ -0,0 +1,257 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import * as api from '@/api/businesses'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { reasonText } from '@/views/system/tplus/workbench-labels'
|
||||
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||
import SalesRelationReview from './SalesRelationReview.vue'
|
||||
|
||||
const props = defineProps<{ businessId: number }>()
|
||||
const user = useUserStore()
|
||||
const readiness = ref<api.SourceReadiness | null>(null)
|
||||
const documents = ref<api.SourceDocument[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const quantities = ref<api.SourceQuantity[]>([])
|
||||
const quantityPage = ref(1)
|
||||
const quantityTotal = ref(0)
|
||||
const quantityBusy = ref(false)
|
||||
const quantityFailed = ref(false)
|
||||
let quantityRequest = 0
|
||||
const busy = ref(false)
|
||||
const loadFailed = ref(false)
|
||||
const saving = ref(false)
|
||||
const priceSaving = ref<number | null>(null)
|
||||
const customerType = ref<string>()
|
||||
const categories = ref<Record<number, string>>({})
|
||||
const detailVisible = ref(false)
|
||||
const detailDocument = ref<api.SourceDocument | null>(null)
|
||||
const detailRows = ref<api.SourceDocumentDetail[]>([])
|
||||
const detailPage = ref(1)
|
||||
const detailTotal = ref(0)
|
||||
const detailBusy = ref(false)
|
||||
const detailFailed = ref(false)
|
||||
let detailRequest = 0
|
||||
const labels: Record<string, string> = {
|
||||
businesses: '订单', business_items: '订单明细', shipments: '发货单',
|
||||
shipment_items: '发货明细', receipts: '回款单',
|
||||
}
|
||||
const states: Record<string, string> = {
|
||||
APPLIED: '已关联', PENDING: '待核对', CONFLICT: '存在差异', EXCLUDED: '不参与',
|
||||
}
|
||||
const detailStates: Record<string, string> = { ADDED: '新增', REMOVED: '已消失', CHANGED: '已变化', UNCHANGED: '无变化' }
|
||||
|
||||
function detailPrice(row: Record<string, string | number | null>): string {
|
||||
if (row.price == null) return '未提供'
|
||||
return `${row.price}(${row.price_type ?? '税别待确认'})`
|
||||
}
|
||||
|
||||
async function loadDetails(): Promise<void> {
|
||||
const doc = detailDocument.value
|
||||
if (!doc) return
|
||||
const request = ++detailRequest
|
||||
detailBusy.value = true
|
||||
detailFailed.value = false
|
||||
try {
|
||||
const result = await api.sourceDocumentDetails(props.businessId, doc.id, { pageNum: detailPage.value, pageSize: 20 })
|
||||
if (request !== detailRequest) return
|
||||
detailRows.value = result.list
|
||||
detailTotal.value = result.total
|
||||
} catch (error) {
|
||||
if (request !== detailRequest) return
|
||||
detailRows.value = []
|
||||
detailTotal.value = 0
|
||||
detailFailed.value = true
|
||||
handledError(error)
|
||||
} finally {
|
||||
if (request === detailRequest) detailBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showDetails(doc: api.SourceDocument): void {
|
||||
detailDocument.value = doc
|
||||
detailPage.value = 1
|
||||
detailRows.value = []
|
||||
detailVisible.value = true
|
||||
void loadDetails()
|
||||
}
|
||||
|
||||
function changeDetailPage(value: number): void { detailPage.value = value; void loadDetails() }
|
||||
|
||||
async function load(): Promise<void> {
|
||||
busy.value = true
|
||||
loadFailed.value = false
|
||||
void loadQuantities()
|
||||
try {
|
||||
const [state, rows] = await Promise.all([
|
||||
api.sourceReadiness(props.businessId),
|
||||
api.sourceDocuments(props.businessId, { pageNum: page.value, pageSize: 20 }),
|
||||
])
|
||||
readiness.value = state
|
||||
documents.value = rows.list
|
||||
total.value = rows.total
|
||||
} catch (error) {
|
||||
readiness.value = null
|
||||
documents.value = []
|
||||
total.value = 0
|
||||
loadFailed.value = true
|
||||
handledError(error)
|
||||
}
|
||||
finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function loadQuantities(): Promise<void> {
|
||||
const request = ++quantityRequest
|
||||
quantityBusy.value = true
|
||||
quantityFailed.value = false
|
||||
try {
|
||||
const summary = await api.sourceQuantities(props.businessId, { pageNum: quantityPage.value, pageSize: 20 })
|
||||
if (request !== quantityRequest) return
|
||||
quantities.value = summary.list
|
||||
quantityTotal.value = summary.total
|
||||
} catch (error) {
|
||||
if (request !== quantityRequest) return
|
||||
quantities.value = []
|
||||
quantityTotal.value = 0
|
||||
quantityFailed.value = true
|
||||
handledError(error)
|
||||
} finally {
|
||||
if (request === quantityRequest) quantityBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save(): Promise<void> {
|
||||
const products = Object.entries(categories.value).filter(([, value]) => value)
|
||||
.map(([id, category]) => ({ product_id: Number(id), category }))
|
||||
if (!customerType.value && !products.length) {
|
||||
ElMessage.warning('请选择需要补充的分类')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await api.saveSourceMaterials(props.businessId, { customer_type: customerType.value, products })
|
||||
customerType.value = undefined
|
||||
categories.value = {}
|
||||
ElMessage.success('分类已保存')
|
||||
await load()
|
||||
} catch (error) { handledError(error) }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
|
||||
function changePage(value: number): void { page.value = value; void load() }
|
||||
function changeQuantityPage(value: number): void { quantityPage.value = value; void loadQuantities() }
|
||||
async function confirmPrice(row: api.SourceDocument): Promise<void> {
|
||||
if (!row.price_repair_revision) return
|
||||
let evidence: string
|
||||
try {
|
||||
const result = await ElMessageBox.prompt(`将空单价补为来源未税单价 ${row.price},不覆盖已有值。`, '确认来源单价', {
|
||||
inputPlaceholder: '填写核对依据', inputValidator: value => !!value?.trim() || '请填写核对依据',
|
||||
confirmButtonText: '确认补齐', cancelButtonText: '取消',
|
||||
})
|
||||
evidence = result.value.trim()
|
||||
} catch { return }
|
||||
priceSaving.value = row.id
|
||||
try {
|
||||
await api.confirmMissingSourcePrice(props.businessId, row.id, { revision: row.price_repair_revision, evidence })
|
||||
ElMessage.success('来源单价已补齐')
|
||||
await load()
|
||||
} catch (error) { handledError(error) }
|
||||
finally { priceSaving.value = null }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="busy" class="source-section">
|
||||
<ListLoadError v-if="loadFailed" :loading="busy" @retry="load" />
|
||||
<template v-else>
|
||||
<h3>核算准备</h3>
|
||||
<template v-if="readiness">
|
||||
<el-tag :type="readiness.status === 'READY' ? 'success' : 'warning'">
|
||||
{{ readiness.status === 'READY' ? '资料已就绪' : '资料待补' }}
|
||||
</el-tag>
|
||||
<p>可参与平账回款(最近封存口径):{{ readiness.eligible_receipts_snapshot == null ? '待确认' : `${readiness.eligible_receipts_snapshot} 元` }}</p>
|
||||
<p>实际计提金额以期初、发货匹配及正式核算结果为准。</p>
|
||||
<ul v-if="readiness.reasons.length" class="reasons">
|
||||
<li v-for="reason in readiness.reasons" :key="reason.code">{{ reason.message }}</li>
|
||||
</ul>
|
||||
<div v-if="!readiness.customer_type || readiness.products.some(p => !p.category)" class="materials">
|
||||
<el-form label-position="top">
|
||||
<el-form-item v-if="!readiness.customer_type" label="客户核算类型">
|
||||
<el-select v-model="customerType" placeholder="请选择" :disabled="!user.can('base:customers:update')">
|
||||
<el-option label="个人户" value="personal" />
|
||||
<el-option label="经理个人户" value="manager_personal" />
|
||||
<el-option label="企业" value="enterprise" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-for="product in readiness.products.filter(p => !p.category)" :key="product.id" :label="`${product.name} ${product.spec || ''}`">
|
||||
<el-select v-model="categories[product.id]" placeholder="产品核算分类" :disabled="!user.can('base:products:update')">
|
||||
<el-option label="市政水利" value="municipal_water" />
|
||||
<el-option label="轨道交通" value="rail_transit" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<p v-if="readiness.products_total > readiness.products.length">产品较多,请在基础数据中继续补充其他产品分类。</p>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存分类</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<SalesRelationReview v-if="readiness?.source_type === 'TPLUS'" :business-id="businessId" @changed="load" />
|
||||
<h3>已接入来源数量</h3>
|
||||
<div v-loading="quantityBusy" class="quantity-section">
|
||||
<ListLoadError v-if="quantityFailed" :loading="quantityBusy" @retry="loadQuantities" />
|
||||
<template v-else>
|
||||
<el-table :data="quantities" size="small" stripe>
|
||||
<el-table-column prop="product_name" label="产品" min-width="150" />
|
||||
<el-table-column prop="spec_model" label="规格" min-width="120" />
|
||||
<el-table-column label="单位" width="90"><template #default="{ row }">{{ row.unit || '未提供' }}</template></el-table-column>
|
||||
<el-table-column prop="order_quantity" label="订单数量" min-width="110" align="right" />
|
||||
<el-table-column prop="shipment_quantity" label="发货数量" min-width="110" align="right" />
|
||||
</el-table>
|
||||
<el-pagination v-if="quantityTotal > 20" layout="prev, pager, next, total" :current-page="quantityPage" :page-size="20" :total="quantityTotal" @current-change="changeQuantityPage" />
|
||||
</template>
|
||||
</div>
|
||||
<h3>来源单据</h3>
|
||||
<el-table :data="documents" size="small" stripe>
|
||||
<el-table-column label="类型" width="90"><template #default="{ row }">{{ labels[row.entity] ?? row.entity }}</template></el-table-column>
|
||||
<el-table-column prop="source_no" label="来源单号" min-width="130" />
|
||||
<el-table-column label="当前来源单号" min-width="130"><template #default="{ row }">{{ row.current_source_no && row.current_source_no !== row.source_no ? row.current_source_no : '-' }}</template></el-table-column>
|
||||
<el-table-column prop="date" label="日期" width="110" />
|
||||
<el-table-column label="来源人员" min-width="130"><template #default="{ row }">{{ row.personnel_name || (row.personnel_source_id ? `待匹配(${row.personnel_source_id})` : '未提供') }}</template></el-table-column>
|
||||
<el-table-column prop="quantity" label="数量" width="90" />
|
||||
<el-table-column label="来源单价" min-width="130"><template #default="{ row }">{{ row.price == null ? '-' : `${row.price}(${row.price_type === 'TAX_INCLUDED' ? '含税' : row.price_type === 'TAX_EXCLUDED' ? '未税' : '税别待确认'})` }}</template></el-table-column>
|
||||
<el-table-column prop="amount" label="金额" width="110" />
|
||||
<el-table-column label="状态" min-width="100"><template #default="{ row }">{{ states[row.status] ?? row.status }}</template></el-table-column>
|
||||
<el-table-column label="待核对原因" min-width="200"><template #default="{ row }">{{ row.reason ? reasonText(row.reason) : '-' }}</template></el-table-column>
|
||||
<el-table-column label="来源变化" min-width="240"><template #default="{ row }"><div v-for="change in row.changes || []" :key="change.field">{{ change.label }}:{{ change.before ?? '未提供' }} → {{ change.after ?? '未提供' }}</div><span v-if="!row.changes?.length">{{ row.snapshot_state === 'MISSING' ? '最新完整快照中没有此明细' : row.snapshot_state === 'UNAVAILABLE' ? '完整快照不可用,显示已接入明细' : row.snapshot_state === 'CHANGED' ? '来源明细已变化,待核对' : row.reason === 'FORMAL_SOURCE_CHANGED' ? '来源字段或明细变化,待核对' : '-' }}</span></template></el-table-column>
|
||||
<el-table-column label="操作" width="110" fixed="right"><template #default="{ row }"><el-button v-if="row.entity === 'businesses' || row.entity === 'shipments'" link type="primary" @click="showDetails(row)">查看明细</el-button><el-button v-if="row.price_repair_revision && user.hasPermission('business:businesses:update') && user.hasPermission('payroll:prepare')" link type="primary" :loading="priceSaving === row.id" @click="confirmPrice(row)">核对单价</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-if="total > 20" layout="prev, pager, next, total" :current-page="page" :page-size="20" :total="total" @current-change="changePage" />
|
||||
<el-dialog v-model="detailVisible" title="来源明细对照" width="min(900px, 94vw)" destroy-on-close>
|
||||
<div v-loading="detailBusy">
|
||||
<ListLoadError v-if="detailFailed" :loading="detailBusy" @retry="loadDetails" />
|
||||
<template v-else>
|
||||
<el-table :data="detailRows" size="small" stripe>
|
||||
<el-table-column prop="source_id" label="来源明细标识" min-width="120" />
|
||||
<el-table-column label="状态" width="85"><template #default="{ row }">{{ detailStates[row.state] ?? row.state }}</template></el-table-column>
|
||||
<el-table-column label="已接入" min-width="220"><template #default="{ row }"><span v-if="row.before">产品 {{ row.before.product_id ?? '未提供' }},数量 {{ row.before.quantity ?? '未提供' }},单价 {{ detailPrice(row.before) }},金额 {{ row.before.amount ?? '未提供' }}</span><span v-else>-</span></template></el-table-column>
|
||||
<el-table-column label="当前来源" min-width="220"><template #default="{ row }"><span v-if="row.after">产品 {{ row.after.product_id ?? '未提供' }},数量 {{ row.after.quantity ?? '未提供' }},单价 {{ detailPrice(row.after) }},金额 {{ row.after.amount ?? '未提供' }}</span><span v-else>-</span></template></el-table-column>
|
||||
<el-table-column label="变化" min-width="200"><template #default="{ row }"><div v-for="change in row.changes" :key="change.field">{{ change.label }}:{{ change.before ?? '未提供' }} → {{ change.after ?? '未提供' }}</div><span v-if="!row.changes.length">{{ row.state === 'CHANGED' ? '其他来源字段变化,待核对' : '-' }}</span></template></el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-if="detailTotal > 20" layout="prev, pager, next, total" :current-page="detailPage" :page-size="20" :total="detailTotal" @current-change="changeDetailPage" />
|
||||
</template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.source-section { margin: 20px 0; }
|
||||
.source-section h3 { margin: 20px 0 12px; font-size: 15px; }
|
||||
.reasons { padding-left: 20px; line-height: 1.8; }
|
||||
.materials { margin: 16px 0; }
|
||||
.materials .el-form { display: flex; flex-wrap: wrap; gap: 0 16px; }
|
||||
.materials .el-select { width: 180px; }
|
||||
</style>
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||
import { optionPage } from '@/api/metadata'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
@@ -36,24 +37,46 @@ import { handledError } from '@/utils/error'
|
||||
const { confirmAndRun } = useConfirmAction()
|
||||
|
||||
import PayrollDialog from './PayrollDialog.vue'
|
||||
import SourceDocuments from './SourceDocuments.vue'
|
||||
|
||||
const payrollOpen = ref(false), payrollBusinessId = ref(0)
|
||||
const payrollOpen = ref(false), payrollBusinessId = ref(0), payrollSourceManaged = ref(false)
|
||||
|
||||
// ==================== 列表 ====================
|
||||
|
||||
const filters = reactive({
|
||||
keyword: '',
|
||||
status: undefined as number | undefined,
|
||||
sourceType: undefined as string | undefined,
|
||||
sourcePersonnel: '',
|
||||
readinessStatus: undefined as 'READY' | 'PENDING' | undefined,
|
||||
readinessReason: undefined as string | undefined,
|
||||
})
|
||||
const readinessReasons = [
|
||||
['CUSTOMER_CATEGORY_PENDING', '客户核算类型待确认'],
|
||||
['PRODUCT_CATEGORY_PENDING', '产品分类待确认'],
|
||||
['PERSONNEL_PENDING', '发货归属及岗位待确认'],
|
||||
['OPENING_BALANCE_PENDING', '期初和历史承接待确认'],
|
||||
['GUIDANCE_PENDING', '指导成本待封存'],
|
||||
['FREIGHT_PENDING', '运费待确认'],
|
||||
['NO_ACTUAL_SHIPMENT', '尚未发货'],
|
||||
['SOURCE_CONFLICT', '来源单据待核对'],
|
||||
['SOURCE_SNAPSHOT_PENDING', '来源快照待接入'],
|
||||
['INPUT_REFRESH_REQUIRED', '核算资料版本待刷新'],
|
||||
['SALES_RELATION_PENDING', '订单与发货关联待核对'],
|
||||
] as const
|
||||
/** 日期范围单独存(el-date-picker daterange 回参是 [起, 止]) */
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
usePagedList<BusinessItem, businessApi.BusinessQueryParams>(
|
||||
businessApi.listBusinesses,
|
||||
() => ({
|
||||
keyword: filters.keyword,
|
||||
status: filters.status,
|
||||
source_type: filters.sourceType,
|
||||
source_personnel: filters.sourcePersonnel || undefined,
|
||||
readiness_status: filters.readinessStatus,
|
||||
readiness_reason: filters.readinessReason,
|
||||
date_from: dateRange.value?.[0],
|
||||
date_to: dateRange.value?.[1],
|
||||
}),
|
||||
@@ -62,6 +85,10 @@ const { records, total, pageNum, pageSize, loading, load, search, onPageChange,
|
||||
function resetFilters(): void {
|
||||
filters.keyword = ''
|
||||
filters.status = undefined
|
||||
filters.sourceType = undefined
|
||||
filters.sourcePersonnel = ''
|
||||
filters.readinessStatus = undefined
|
||||
filters.readinessReason = undefined
|
||||
dateRange.value = null
|
||||
search()
|
||||
}
|
||||
@@ -259,7 +286,7 @@ async function openEdit(row: BusinessItem): Promise<void> {
|
||||
product_name: it.product_name,
|
||||
spec_model: it.spec_model ?? '',
|
||||
unit: it.unit ?? '',
|
||||
product_category: it.product_category,
|
||||
product_category: it.product_category ?? '',
|
||||
quantity: it.quantity,
|
||||
sale_price: it.sale_price ?? '',
|
||||
cost_amount: it.cost_amount ?? '',
|
||||
@@ -614,6 +641,7 @@ onMounted(() => {
|
||||
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排;
|
||||
查询/重置收进 .filter-bar__actions,由 margin-left:auto 推到行尾。
|
||||
-->
|
||||
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="关键词">
|
||||
<el-input
|
||||
@@ -634,6 +662,26 @@ onMounted(() => {
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="来源">
|
||||
<el-select v-model="filters.sourceType" placeholder="全部" clearable style="width: 110px">
|
||||
<el-option label="T+" value="TPLUS" />
|
||||
<el-option label="手工" value="MANUAL" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="来源人员">
|
||||
<el-input v-model="filters.sourcePersonnel" clearable style="width: 140px" @keyup.enter="search" />
|
||||
</el-form-item>
|
||||
<el-form-item label="核算准备">
|
||||
<el-select v-model="filters.readinessStatus" placeholder="全部" clearable style="width: 120px">
|
||||
<el-option label="已准备" value="READY" />
|
||||
<el-option label="待补资料" value="PENDING" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="待补原因">
|
||||
<el-select v-model="filters.readinessReason" placeholder="全部" clearable style="width: 190px">
|
||||
<el-option v-for="[value, label] in readinessReasons" :key="value" :label="label" :value="value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="业务日期">
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
@@ -652,7 +700,7 @@ onMounted(() => {
|
||||
</el-form>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="openCreate" :disabled="!permissionUser.can('business:businesses:create')" :title="permissionUser.permissionReason('business:businesses:create')">新增业务</el-button>
|
||||
<el-button type="primary" @click="openCreate" :disabled="!permissionUser.can('business:businesses:create')" :title="permissionUser.permissionReason('business:businesses:create')">手工新增业务</el-button>
|
||||
<span class="toolbar-note">状态流转与归属调整走行内操作,每次变更都写留痕</span>
|
||||
</div>
|
||||
|
||||
@@ -662,7 +710,7 @@ onMounted(() => {
|
||||
v-loading="loading"
|
||||
:data="records"
|
||||
stripe
|
||||
empty-text="还没有业务记录 —— 点「新增业务」建第一笔,或换个关键词再查"
|
||||
empty-text="暂无符合条件的业务,可调整筛选条件。"
|
||||
>
|
||||
<el-table-column label="业务编号" min-width="150">
|
||||
<template #default="{ row }">
|
||||
@@ -672,6 +720,18 @@ onMounted(() => {
|
||||
<el-table-column prop="customer_name" label="客户名称" min-width="160">
|
||||
<template #default="{ row }">{{ row.customer_name ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" width="90"><template #default="{ row }">{{ row.source_type === 'TPLUS' ? 'T+' : '手工' }}</template></el-table-column>
|
||||
<el-table-column label="来源人员" min-width="110"><template #default="{ row }">{{ row.source_personnel?.join('、') || '—' }}</template></el-table-column>
|
||||
<el-table-column label="核算准备" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.readiness_status === 'READY'">已准备</span>
|
||||
<el-tooltip v-else-if="row.readiness_status === 'PENDING'" placement="top">
|
||||
<template #content><div v-for="reason in row.readiness_reasons" :key="reason.code">{{ reason.message }}</div></template>
|
||||
<span>待补资料({{ row.readiness_reasons.length }})</span>
|
||||
</el-tooltip>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="business_date" label="业务日期" min-width="110">
|
||||
<template #default="{ row }">{{ row.business_date ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
@@ -710,23 +770,22 @@ onMounted(() => {
|
||||
<span class="cell-num">{{ row.total_received_amount ?? '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="300" fixed="right" align="center">
|
||||
<el-table-column label="操作" width="400" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openDrawer(row)">详情</el-button>
|
||||
<el-button link type="primary" size="small" @click="payrollBusinessId = row.id; payrollOpen = true" :disabled="!permissionUser.can('payroll:view')" :title="permissionUser.permissionReason('payroll:view')">正式核算</el-button>
|
||||
<el-button link type="primary" size="small" @click="payrollBusinessId = row.id; payrollSourceManaged = !!row.integration_status; payrollOpen = true" :disabled="!permissionUser.can('payroll:view')" :title="permissionUser.permissionReason('payroll:view')">正式核算</el-button>
|
||||
<el-tooltip
|
||||
content="已完成或已终止的业务禁止编辑"
|
||||
:disabled="row.status === 1"
|
||||
placement="top"
|
||||
>
|
||||
<span class="op-wrap">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">详情</el-button>
|
||||
<el-button
|
||||
link
|
||||
type="primary"
|
||||
size="small"
|
||||
:disabled="!permissionUser.can('business:businesses:update') || (row.status !== 1)"
|
||||
:disabled="!permissionUser.can('business:businesses:update') || row.status !== 1 || row.integration_status !== null"
|
||||
@click="openEdit(row)"
|
||||
:title="permissionUser.permissionReason('business:businesses:update')">
|
||||
编辑
|
||||
@@ -750,7 +809,7 @@ onMounted(() => {
|
||||
归属
|
||||
</el-button>
|
||||
<span class="table-actions__divider" aria-hidden="true"></span>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)" :disabled="!permissionUser.can('business:businesses:delete')" :title="permissionUser.permissionReason('business:businesses:delete')">删除</el-button>
|
||||
<el-button link type="danger" size="small" @click="handleDelete(row)" :disabled="!permissionUser.can('business:businesses:delete') || row.integration_status !== null" :title="permissionUser.permissionReason('business:businesses:delete')">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -771,11 +830,15 @@ onMounted(() => {
|
||||
<!-- 新增 / 编辑(含明细行编辑器;编辑不含归属,归属走独立按钮保证留痕) -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:close-on-click-modal="!submitting"
|
||||
:close-on-press-escape="!submitting"
|
||||
:show-close="!submitting"
|
||||
:title="isEdit ? '编辑业务' : '新增业务'"
|
||||
width="980px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="formRef" :disabled="!permissionUser.can(isEdit ? 'business:businesses:update' : 'business:businesses:create')" :model="form" :rules="rules" label-width="96px">
|
||||
<h2 class="form-section-title">业务信息</h2>
|
||||
<el-row :gutter="16">
|
||||
<el-col :span="12">
|
||||
<el-form-item label="客户" prop="customer_id">
|
||||
@@ -809,7 +872,7 @@ onMounted(() => {
|
||||
<el-form-item label="负责人员" prop="owner_personnel_id">
|
||||
<el-select
|
||||
v-model="form.owner_personnel_id"
|
||||
placeholder="不选择=待指定"
|
||||
placeholder="选填,稍后指定"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 100%"
|
||||
@@ -864,14 +927,15 @@ onMounted(() => {
|
||||
<div v-if="allocationRows.length" class="items-head"><span class="items-title">历史比例方案(仅供查看)</span></div>
|
||||
<el-table v-if="allocationRows.length" :data="allocationRows" size="small">
|
||||
<el-table-column label="原参与人员"><template #default="{row}">{{ personnelOptions.find(p => p.id === row.personnel_id)?.name ?? row.personnel_id }}</template></el-table-column>
|
||||
<el-table-column prop="ratio" label="原分配比例(%)" />
|
||||
<el-table-column prop="ratio" label="原分配比例(%)" align="right" />
|
||||
</el-table>
|
||||
<p class="page-desc">正式核算按各人发货对应的绩效分配。请在“正式核算”中维护发货归属,历史比例方案仅供查看。</p>
|
||||
|
||||
<div class="items-head">
|
||||
<span class="items-title">产品明细(至少 1 行,编辑为全量替换)</span>
|
||||
<span class="items-title">产品明细</span>
|
||||
<el-button size="small" @click="addItemRow" :disabled="!permissionUser.can(isEdit ? 'business:businesses:update' : 'business:businesses:create')">添加一行</el-button>
|
||||
</div>
|
||||
<p class="form-hint">至少保留一行产品。保存时以当前明细替换原有明细。</p>
|
||||
<el-table :data="itemRows" stripe size="small" class="items-table">
|
||||
<el-table-column label="产品名称" min-width="170">
|
||||
<template #default="{ row }">
|
||||
@@ -911,7 +975,7 @@ onMounted(() => {
|
||||
<el-input v-model="row.cost_amount" placeholder="元,选填" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="含税单位指导成本" min-width="110">
|
||||
<el-table-column label="含税单位指导成本" min-width="155">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.guided_price" placeholder="元/单位,选填" />
|
||||
</template>
|
||||
@@ -929,7 +993,7 @@ onMounted(() => {
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button :disabled="submitting" @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can((isEdit ? 'business:businesses:update' : 'business:businesses:create'))" :title="permissionUser.permissionReason((isEdit ? 'business:businesses:update' : 'business:businesses:create'))">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
@@ -1025,6 +1089,7 @@ onMounted(() => {
|
||||
<!-- 详情抽屉:基本信息 / 产品明细 / 状态时间线 / 归属留痕 / 毛利润视图 -->
|
||||
<el-drawer v-model="drawerVisible" :title="drawerTitle" size="920px" destroy-on-close>
|
||||
<div v-loading="drawerLoading">
|
||||
<SourceDocuments v-if="detail?.integration_status && drawerRowId" :business-id="drawerRowId" />
|
||||
<div class="sub-section">
|
||||
<div class="sub-section-title">基本信息</div>
|
||||
<el-descriptions v-if="detail" :column="3" border size="small" class="detail-desc">
|
||||
@@ -1083,7 +1148,7 @@ onMounted(() => {
|
||||
<div class="sub-section">
|
||||
<div class="sub-section-title">产品明细</div>
|
||||
<el-table v-if="detail" :data="detail.items" stripe size="small">
|
||||
<el-table-column prop="product_name" label="产品名称" min-width="150" />
|
||||
<el-table-column prop="product_name" label="产品名称" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column label="规格型号" min-width="110">
|
||||
<template #default="{ row }">{{ row.spec_model ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
@@ -1132,7 +1197,7 @@ onMounted(() => {
|
||||
<el-table-column label="版本" width="70" align="center">
|
||||
<template #default="{ row }">V{{ row.version_no }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="personnel_name" label="参与业务员" min-width="140" />
|
||||
<el-table-column prop="personnel_name" label="参与业务员" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="分配比例" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-num">{{ row.ratio }}%</span>
|
||||
@@ -1228,7 +1293,7 @@ onMounted(() => {
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="gp-alert"
|
||||
:title="`共 ${gp.missing_count} 行成本缺失,无法计算毛利润,不计入类别汇总`"
|
||||
:title="`共 ${gp.missing_count} 行资料待补或来源待核对,无法计算毛利润,不计入类别汇总`"
|
||||
/>
|
||||
<el-row v-if="gp.categories.length > 0" :gutter="12">
|
||||
<el-col v-for="cat in gp.categories" :key="cat.product_category" :span="12">
|
||||
@@ -1258,7 +1323,7 @@ onMounted(() => {
|
||||
<div v-else class="cell-muted">暂无可汇总的明细数据</div>
|
||||
|
||||
<el-table v-if="gp.rows.length > 0" :data="gp.rows" stripe size="small" class="gp-rows">
|
||||
<el-table-column prop="product_name" label="产品名称" min-width="140" />
|
||||
<el-table-column prop="product_name" label="产品名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="规格型号" min-width="100">
|
||||
<template #default="{ row }">{{ row.spec_model ?? '—' }}</template>
|
||||
</el-table-column>
|
||||
@@ -1310,7 +1375,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
<PayrollDialog v-model="payrollOpen" :business-id="payrollBusinessId" />
|
||||
<PayrollDialog v-model="payrollOpen" :business-id="payrollBusinessId" :source-managed="payrollSourceManaged" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||
import { optionPage } from '@/api/metadata'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
@@ -11,6 +12,7 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
|
||||
import * as businessApi from '@/api/businesses'
|
||||
import * as receiptApi from '@/api/receipts'
|
||||
import { submitChanges } from '@/api/params'
|
||||
import type { ReceiptItem } from '@/api/receipts'
|
||||
import * as shipmentApi from '@/api/shipments'
|
||||
import type { ShipmentItem } from '@/api/shipments'
|
||||
@@ -34,6 +36,36 @@ import { handledError } from '@/utils/error'
|
||||
* - 金额字符串直出与提交(金额红线:不经 float、不 toFixed)
|
||||
*/
|
||||
const { confirmAndRun } = useConfirmAction()
|
||||
const confirmationEnabled = ref<boolean | null>(null)
|
||||
const policyBusy = ref(false)
|
||||
|
||||
async function loadConfirmationPolicy(): Promise<void> {
|
||||
try { confirmationEnabled.value = (await receiptApi.getConfirmationPolicy()).enabled }
|
||||
catch (error) { confirmationEnabled.value = null; handledError(error) }
|
||||
}
|
||||
|
||||
async function changeConfirmationPolicy(value: string | number | boolean): Promise<void> {
|
||||
if (typeof value !== 'boolean' || policyBusy.value) return
|
||||
policyBusy.value = true
|
||||
try {
|
||||
await confirmAndRun({
|
||||
title: '回款人工确认开关',
|
||||
confirmText: value ? '开启后,未人工确认的回款不能正式核算。' : '关闭仅跳过人工确认,仍检查凭证等资格;工资审核开关不变。',
|
||||
successText: '回款人工确认设置已保存',
|
||||
run: () => submitChanges({ change_type: 1, remark: '在回款管理调整人工确认开关', items: [{ param_code: 'receipt.confirmation.enabled', dimension_value: '', value: value ? '1' : '0' }] }),
|
||||
after: loadConfirmationPolicy,
|
||||
})
|
||||
} finally { policyBusy.value = false }
|
||||
}
|
||||
|
||||
async function handleConfirm(row: ReceiptItem): Promise<void> {
|
||||
await confirmAndRun({
|
||||
title: '人工确认回款',
|
||||
confirmText: `确认已人工核对回款 ${row.receipt_batch_no} 的到账日 ${row.receipt_date}、金额 ${row.amount} 元及业务归属?此操作不生成绩效或工资,也不代替凭证资格。`,
|
||||
successText: '回款已人工确认',
|
||||
run: () => receiptApi.confirmReceipt(row), after: load,
|
||||
})
|
||||
}
|
||||
|
||||
// ==================== 列表 ====================
|
||||
|
||||
@@ -41,17 +73,19 @@ const filters = reactive({
|
||||
business_id: undefined as number | undefined,
|
||||
is_barter: undefined as number | undefined,
|
||||
settlement_status: undefined as number | undefined,
|
||||
confirmation_status: undefined as number | undefined,
|
||||
})
|
||||
/** 回款日期范围单独存(el-date-picker daterange 回参是 [起, 止]) */
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
usePagedList<ReceiptItem, receiptApi.ReceiptQueryParams>(
|
||||
receiptApi.listReceipts,
|
||||
() => ({
|
||||
business_id: filters.business_id,
|
||||
is_barter: filters.is_barter,
|
||||
settlement_status: filters.settlement_status,
|
||||
confirmation_status: filters.confirmation_status,
|
||||
date_from: dateRange.value?.[0],
|
||||
date_to: dateRange.value?.[1],
|
||||
}),
|
||||
@@ -61,6 +95,7 @@ function resetFilters(): void {
|
||||
filters.business_id = undefined
|
||||
filters.is_barter = undefined
|
||||
filters.settlement_status = undefined
|
||||
filters.confirmation_status = undefined
|
||||
dateRange.value = null
|
||||
search()
|
||||
}
|
||||
@@ -240,9 +275,11 @@ async function handleDelete(row: ReceiptItem): Promise<void> {
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
const initialListPending = ref(true)
|
||||
onMounted(() => {
|
||||
void loadConfirmationPolicy()
|
||||
// 两个请求各自容错(load 内部已兜底),互不拖累
|
||||
void load()
|
||||
void load().finally(() => { initialListPending.value = false })
|
||||
void loadBusinessOptions()
|
||||
})
|
||||
</script>
|
||||
@@ -256,10 +293,20 @@ onMounted(() => {
|
||||
<span class="waterline"></span>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<span>独立人工确认</span>
|
||||
<el-switch :model-value="confirmationEnabled === true" :loading="policyBusy"
|
||||
:disabled="confirmationEnabled === null || !permissionUser.can('system:params:update')"
|
||||
aria-label="回款人工确认开关" @change="changeConfirmationPolicy" />
|
||||
<span class="toolbar-note">{{ confirmationEnabled === null ? '状态读取失败,请重新加载' : confirmationEnabled ? '已开启:确认后方可正式核算' : '已关闭:仍须满足凭证等核算资格' }};工资审核独立设置。</span>
|
||||
<el-button v-if="confirmationEnabled === null" link @click="loadConfirmationPolicy">重新加载确认设置</el-button>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
|
||||
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
||||
-->
|
||||
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="业务">
|
||||
<el-select
|
||||
@@ -329,7 +376,7 @@ onMounted(() => {
|
||||
v-loading="loading"
|
||||
:data="records"
|
||||
stripe
|
||||
empty-text="还没有回款记录 —— 点「新增回款」登记第一笔,或换个业务再查"
|
||||
:empty-text="loading || initialListPending ? '回款记录加载中...' : loadFailed ? '回款记录加载失败,请重试。' : '暂无符合条件的回款记录,可调整筛选条件。'"
|
||||
>
|
||||
<el-table-column label="批次号" min-width="150">
|
||||
<template #default="{ row }">
|
||||
@@ -379,9 +426,18 @@ onMounted(() => {
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130" fixed="right" align="center">
|
||||
<el-table-column label="人工确认" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.confirmation_status === 1 ? '已确认' : '待确认' }}</span>
|
||||
<div v-if="row.confirmation_status === 1" class="cell-muted">{{ row.confirmed_by }} · {{ row.confirmed_at }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="265" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small"
|
||||
:disabled="row.confirmation_status === 1 || !permissionUser.can('business:receipts:confirm')"
|
||||
:title="permissionUser.permissionReason('business:receipts:confirm')" @click="handleConfirm(row)">人工确认</el-button>
|
||||
<el-tooltip
|
||||
content="已核算回款不可变更"
|
||||
:disabled="row.settlement_status !== 1"
|
||||
@@ -438,6 +494,9 @@ onMounted(() => {
|
||||
<!-- 新增 / 编辑(业务必选 + 发货批次级联指定,可清空=暂不指定) -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:close-on-click-modal="!submitting"
|
||||
:close-on-press-escape="!submitting"
|
||||
:show-close="!submitting"
|
||||
:title="isEdit ? '编辑回款' : '新增回款'"
|
||||
width="620px"
|
||||
destroy-on-close
|
||||
@@ -503,7 +562,7 @@ onMounted(() => {
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button :disabled="submitting" @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can((isEdit ? 'business:receipts:update' : 'business:receipts:create'))" :title="permissionUser.permissionReason((isEdit ? 'business:receipts:update' : 'business:receipts:create'))">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||
import { optionPage } from '@/api/metadata'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
@@ -40,7 +41,7 @@ const filters = reactive({
|
||||
/** 发货日期范围单独存(el-date-picker daterange 回参是 [起, 止]) */
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
const { records, total, pageNum, pageSize, loadFailed, loading, load, search, onPageChange, onSizeChange, reloadAfterDelete } =
|
||||
usePagedList<ShipmentItem, shipmentApi.ShipmentQueryParams>(
|
||||
shipmentApi.listShipments,
|
||||
() => ({
|
||||
@@ -271,9 +272,10 @@ async function handleDelete(row: ShipmentItem): Promise<void> {
|
||||
|
||||
// ==================== 初始化 ====================
|
||||
|
||||
const initialListPending = ref(true)
|
||||
onMounted(() => {
|
||||
// 两个请求各自容错(load 内部已兜底),互不拖累
|
||||
void load()
|
||||
void load().finally(() => { initialListPending.value = false })
|
||||
void loadBusinessOptions()
|
||||
})
|
||||
</script>
|
||||
@@ -293,6 +295,7 @@ onMounted(() => {
|
||||
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 不是 flex 透明容器,
|
||||
外面再包一层 div 会让 el-form-item 丢掉 gap 排版,筛选条退化成竖排。
|
||||
-->
|
||||
<ListLoadError v-if="loadFailed" :loading="loading" @retry="load" />
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="业务">
|
||||
<el-select
|
||||
@@ -337,7 +340,7 @@ onMounted(() => {
|
||||
v-loading="loading"
|
||||
:data="records"
|
||||
stripe
|
||||
empty-text="还没有发货单 —— 点「新增发货单」登记第一批,或换个业务再查"
|
||||
:empty-text="loading || initialListPending ? '发货单加载中...' : loadFailed ? '发货单加载失败,请重试。' : '暂无符合条件的发货单,可调整筛选条件。'"
|
||||
>
|
||||
<el-table-column label="发货单号" min-width="150">
|
||||
<template #default="{ row }">
|
||||
@@ -362,7 +365,7 @@ onMounted(() => {
|
||||
<el-table-column label="运费(元)" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<!-- 字符串直出:金额红线,禁 toFixed / float 格式化 -->
|
||||
<span class="cell-num">{{ row.freight_amount }}</span>
|
||||
<span class="cell-num">{{ row.freight_amount ?? '待确认' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发货总额(元)" min-width="130" align="right">
|
||||
@@ -370,7 +373,7 @@ onMounted(() => {
|
||||
<span class="cell-num">{{ row.total_amount }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="130" fixed="right" align="center">
|
||||
<el-table-column label="操作" width="190" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<el-button link type="primary" size="small" @click="openEdit(row)">详情</el-button>
|
||||
@@ -397,6 +400,9 @@ onMounted(() => {
|
||||
<!-- 新增 / 编辑(明细行编辑器;编辑不含 business_id,挂靠业务不可改) -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:close-on-click-modal="!submitting"
|
||||
:close-on-press-escape="!submitting"
|
||||
:show-close="!submitting"
|
||||
:title="isEdit ? '编辑发货单' : '新增发货单'"
|
||||
width="820px"
|
||||
destroy-on-close
|
||||
@@ -479,7 +485,7 @@ onMounted(() => {
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button :disabled="submitting" @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit" :disabled="!permissionUser.can((isEdit ? 'business:shipments:update' : 'business:shipments:create'))" :title="permissionUser.permissionReason((isEdit ? 'business:shipments:update' : 'business:shipments:create'))">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ListLoadError from '@/components/feedback/ListLoadError.vue'
|
||||
import { optionPage } from '@/api/metadata'
|
||||
import { useUserStore as usePermissionUser } from '@/store/user'
|
||||
const permissionUser = usePermissionUser()
|
||||
@@ -69,7 +70,7 @@ function fetchPage(params: ReportFilterParams) {
|
||||
}
|
||||
|
||||
// summary 只能由最新请求写入:fetcher 内的副作用不受 usePagedList 序号保护
|
||||
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } =
|
||||
const { records, total, pageNum, pageSize, loadFailed, loading, search, onPageChange, onSizeChange } =
|
||||
usePagedList(fetchPage, getFilters, (data) => {
|
||||
summary.value = data.summary
|
||||
})
|
||||
@@ -172,6 +173,7 @@ function today(): string {
|
||||
.filter-bar 直接挂在 el-form 上(去掉 inline):el-form 本身就是 flex 容器,
|
||||
外面再包一层 div 会让 el-form-item 的 gap 排版失效。
|
||||
-->
|
||||
<ListLoadError v-if="loadFailed" :loading="loading" @retry="search" />
|
||||
<el-form class="filter-bar" @submit.prevent>
|
||||
<el-form-item label="关键字">
|
||||
<el-input
|
||||
@@ -262,11 +264,11 @@ function today(): string {
|
||||
<el-table-column prop="receipt_date" label="回款日" width="110" />
|
||||
<el-table-column prop="amount" label="回款金额" width="120" align="right" />
|
||||
<el-table-column prop="settlement_status" label="入结状态" width="100" />
|
||||
<el-table-column prop="remark" label="备注" min-width="140" />
|
||||
<el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<p class="section-title">台账明细</p>
|
||||
<el-table :data="ledgersMap[row.business_id] ?? []" size="small" stripe>
|
||||
<el-table-column prop="personnel_name" label="归属人员" width="120" />
|
||||
<el-table-column prop="personnel_name" label="归属人员" width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="receipt_date" label="回款日" width="110" />
|
||||
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
|
||||
<el-table-column prop="rate_text" label="计提比例" width="110" />
|
||||
@@ -281,7 +283,7 @@ function today(): string {
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="business_no" label="业务编号" min-width="160">
|
||||
<el-table-column prop="business_no" label="业务编号" min-width="160" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<span class="cell-code">{{ row.business_no }}</span>
|
||||
</template>
|
||||
@@ -294,7 +296,7 @@ function today(): string {
|
||||
</el-table-column>
|
||||
<el-table-column prop="total_sale_amount" label="销售额" width="120" align="right">
|
||||
<template #default="{ row }">
|
||||
<span class="cell-num">{{ row.total_sale_amount }}</span>
|
||||
<span class="cell-num">{{ row.total_sale_amount ?? '待核对' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="total_ship_amount" label="发货额" width="120" align="right">
|
||||
@@ -325,7 +327,8 @@ function today(): string {
|
||||
<div v-if="summary" class="summary-bar">
|
||||
全部筛选结果:{{ summary.row_count }} 笔业务 | 发货合计
|
||||
{{ summary.ship_total ?? '0.00' }} | 回款合计 {{ summary.received_total ?? '0.00' }} |
|
||||
绩效合计 {{ summary.performance_total ?? '0.00' }}
|
||||
绩效合计 {{ summary.performance_total ?? '0.00' }} | 销售额合计
|
||||
{{ summary.sale_total ?? '待核对' }}<span v-if="summary.sale_pending_count">({{ summary.sale_pending_count }} 笔待核对)</span>
|
||||
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ const linkLabels: Record<string, string> = {
|
||||
unambiguous_order_matches: '订单关联一致', invalid_order_ids: '关联订单不存在', conflicting_order_ids: '关联订单不一致',
|
||||
}
|
||||
const rows = computed(() => Object.entries(props.report.sales_links ?? {}).map(([key, value]) => ({
|
||||
label: linkLabels[key] ?? key, value,
|
||||
key, label: linkLabels[key] ?? key, value,
|
||||
})))
|
||||
</script>
|
||||
|
||||
@@ -18,22 +18,46 @@ const rows = computed(() => Object.entries(props.report.sales_links ?? {}).map((
|
||||
<section class="audit-result" aria-label="关联核验结果">
|
||||
<p class="audit-caption">检查时间:{{ formatDateTime(report.captured_at) }}</p>
|
||||
<p v-if="report.relationship_status !== 'OBSERVED'" role="alert">无法确认单据类型,本次未完成关联检查。请核对数据后重新检查。</p>
|
||||
<div class="audit-summary">
|
||||
<div class="audit-checks">
|
||||
<el-table v-if="rows.length" :data="rows" size="small" class="audit-table">
|
||||
<el-table-column prop="label" label="核验项目" min-width="190" />
|
||||
<el-table-column label="明细行数" width="120" align="right"><template #default="{ row }"><span class="cell-num">{{ row.value.toLocaleString() }}</span></template></el-table-column>
|
||||
<el-table-column label="明细行数" width="120" align="right"><template #default="{ row }"><span class="cell-num" :class="{'has-issue': ['invalid_order_ids','conflicting_order_ids'].includes(row.key) && row.value > 0}">{{ row.value.toLocaleString() }}</span></template></el-table-column>
|
||||
</el-table>
|
||||
<p v-if="report.order_coverage" class="coverage-note">按关联销货单统计:无订单 {{ report.order_coverage.without_order?.toLocaleString() }} 行,单订单 {{ report.order_coverage.single_order?.toLocaleString() }} 行,多订单 {{ report.order_coverage.multiple_orders?.toLocaleString() }} 行。统计单位为收款明细行,同一销货单可能出现多次。</p>
|
||||
<h3>收付款统计</h3>
|
||||
<p>{{ report.receipt_kinds?.map((item) => `${item.voucher_type_name} ${item.row_count.toLocaleString()} 条`).join(';') || '暂无统计' }}</p>
|
||||
<p v-if="report.state_enums?.length">单据状态:{{ report.state_enums.map((item) => item.name).join('、') }}</p>
|
||||
<p v-if="report.order_coverage" class="coverage-note">按关联销货单核对,统计单位为收款明细行,同一销货单可能出现多次。</p>
|
||||
</div>
|
||||
<aside class="audit-aside" aria-label="单据关联与收付款统计">
|
||||
<template v-if="report.order_coverage">
|
||||
<h3>订单关联分布</h3>
|
||||
<dl class="audit-counts"><div v-for="[key,label] in [['without_order','无订单'],['single_order','单订单'],['multiple_orders','多订单']]" :key="key"><dt>{{ label }}</dt><dd>{{ report.order_coverage[key!]?.toLocaleString() ?? '未记录' }} <small>行</small></dd></div></dl>
|
||||
</template>
|
||||
<h3>收付款统计</h3>
|
||||
<dl v-if="report.receipt_kinds?.length" class="audit-counts"><div v-for="item in report.receipt_kinds" :key="item.voucher_type_name"><dt>{{ item.voucher_type_name }}</dt><dd>{{ item.row_count.toLocaleString() }} <small>条</small></dd></div></dl>
|
||||
<p v-else>暂无统计</p>
|
||||
<p v-if="report.state_enums?.length" class="coverage-note">单据状态:{{ report.state_enums.map((item) => item.name).join('、') }}</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.audit-result { line-height: 1.7; }
|
||||
.audit-result { container-type: inline-size; line-height: 1.7; }
|
||||
.audit-summary { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(230px, 0.75fr); gap: 24px; margin-top: 18px; }
|
||||
.audit-checks { min-width: 0; }
|
||||
.audit-aside { min-width: 0; padding-left: 24px; border-left: 1px solid var(--ys-line); }
|
||||
.audit-caption { margin: 0; font-size: 12px; }
|
||||
.audit-caption, .coverage-note, dd { color: var(--ys-ink-2); }
|
||||
.audit-table { margin-top: 16px; }
|
||||
h3 { font-size: 14px; margin: 20px 0 8px; }
|
||||
.audit-table { margin-top: 0; }
|
||||
h3 { font-size: 14px; margin: 20px 0 12px; font-weight: 600; }
|
||||
h3:first-child { margin-top: 0; }
|
||||
.audit-counts { margin: 0; }
|
||||
.audit-counts > div { display: flex; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--ys-line-soft); font-size: 13px; }
|
||||
.audit-counts dt { color: var(--ys-ink-2); font-weight: 400; }
|
||||
.audit-counts dd { margin: 0; color: var(--ys-ink); font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
.audit-counts small { font-size: 12px; color: var(--ys-ink-3); font-weight: 400; }
|
||||
.coverage-note { font-size: 12px; line-height: 1.8; }
|
||||
.has-issue { color: var(--el-color-danger-dark-2); font-weight: 600; }
|
||||
dt { font-weight: 600; }
|
||||
dd { margin: 0 0 12px; }
|
||||
@container (max-width: 720px) { .audit-summary { grid-template-columns: minmax(0, 1fr); } .audit-aside { padding-left: 0; padding-top: 20px; border-left: 0; border-top: 1px solid var(--ys-line); } }
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as api from '@/api/tplus-workbench'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { newRequestKey } from '@/utils/uuid'
|
||||
import { reasonText } from './workbench-labels'
|
||||
|
||||
const user = useUserStore()
|
||||
const config = ref<api.BusinessSyncConfig | null>(null)
|
||||
const preview = ref<api.BusinessSyncPreview | null>(null)
|
||||
const latest = ref<api.WorkRun | null>(null)
|
||||
const startDate = ref('2026-03-01')
|
||||
const busy = ref(false)
|
||||
const loadFailed = ref(false)
|
||||
const labels: Record<string, string> = { businesses: '订单', shipments: '发货', receipts: '收款' }
|
||||
const statuses: Record<string, string> = { QUEUED: '等待处理', RUNNING: '正在接入', SUCCEEDED: '接入完成', PARTIAL: '部分待核对', FAILED: '接入失败' }
|
||||
let timer: number | undefined
|
||||
|
||||
async function refresh(notify = true): Promise<void> {
|
||||
try {
|
||||
const [settings, runs] = await Promise.all([api.businessSyncConfig(), api.listWorks({kind: 'BUSINESS_SYNC', pageSize: 1})])
|
||||
const previous = config.value
|
||||
if (!previous || (!busy.value && (settings.enabled || (!preview.value && startDate.value === previous.start_date)))) {
|
||||
startDate.value = settings.start_date
|
||||
}
|
||||
config.value = settings
|
||||
latest.value = runs.list[0] ?? null
|
||||
loadFailed.value = false
|
||||
} catch (error) { loadFailed.value = true; if (notify) handledError(error) }
|
||||
}
|
||||
|
||||
async function inspect(): Promise<void> {
|
||||
busy.value = true
|
||||
try { preview.value = await api.previewBusinessSync(startDate.value) }
|
||||
catch (error) { handledError(error) }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function setEnabled(value: string | number | boolean): Promise<void> {
|
||||
if (!config.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
config.value = await api.saveBusinessSync({enabled: !!value, start_date: startDate.value, expected_version: config.value.version})
|
||||
ElMessage.success(value ? '业务自动接入已开启' : '业务自动接入已关闭')
|
||||
await refresh()
|
||||
} catch (error) { handledError(error); await refresh() }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
busy.value = true
|
||||
try {
|
||||
latest.value = await api.runBusinessSync(startDate.value, newRequestKey())
|
||||
ElMessage.success('业务接入任务已排队')
|
||||
} catch (error) { handledError(error) }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => { void refresh(); timer = window.setInterval(() => { void refresh(false) }, 15000) })
|
||||
onBeforeUnmount(() => { if (timer !== undefined) window.clearInterval(timer) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="business-sync" aria-label="业务接入">
|
||||
<div class="heading">
|
||||
<h2>业务自动接入</h2>
|
||||
<el-switch :model-value="!!config?.enabled" :loading="busy" :disabled="!config?.editable || !user.can('tplus:import')" aria-label="业务自动接入开关" @change="setEnabled" />
|
||||
</div>
|
||||
<div class="controls">
|
||||
<span>接入起始日期</span>
|
||||
<el-date-picker v-model="startDate" type="date" value-format="YYYY-MM-DD" :clearable="false" :disabled="busy || !!config?.enabled" />
|
||||
<el-button :loading="busy" :disabled="!config?.editable || !user.can('tplus:import')" @click="inspect">只读预览</el-button>
|
||||
<el-button :loading="busy" :disabled="!config?.editable || !user.can('tplus:import')" @click="run">回填或重试</el-button>
|
||||
<el-button @click="refresh()">刷新</el-button>
|
||||
</div>
|
||||
<p v-if="loadFailed">接入状态加载失败,请刷新重试。</p>
|
||||
<p v-else-if="!config?.editable">等待首次完整采集完成。</p>
|
||||
<p v-else>{{ config.enabled ? '已开启' : '未开启' }}<span v-if="config.last_error">,接入状态需要检查:{{ reasonText(config.last_error) }}</span></p>
|
||||
<el-table v-if="preview" :data="Object.entries(preview.candidates).map(([entity, counts]) => ({entity, ...counts}))" size="small">
|
||||
<el-table-column label="来源单据"><template #default="{row}">{{ labels[row.entity] ?? row.entity }}</template></el-table-column>
|
||||
<el-table-column prop="new" label="未接入候选" />
|
||||
<el-table-column prop="existing" label="已接入待比对" />
|
||||
</el-table>
|
||||
<p v-if="preview">已有差异:{{ preview.existing_conflicts }} 条</p>
|
||||
<template v-if="preview?.customer_scope">
|
||||
<p>候选客户:{{ preview.customer_scope.candidate_customers }},尚未关联业务:{{ preview.customer_scope.without_business_link }},身份风险:{{ preview.customer_scope.identity_warnings }}</p>
|
||||
<p>{{ preview.customer_scope.note }}</p>
|
||||
<el-table v-if="preview.customer_scope.examples.length" :data="preview.customer_scope.examples" size="small" max-height="240">
|
||||
<el-table-column prop="source_id" label="来源客户标识" width="150" />
|
||||
<el-table-column label="待核对原因"><template #default="{ row }">{{ reasonText(row.reason) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<p v-if="preview.customer_scope.truncated">仅展示前 100 条身份风险。</p>
|
||||
</template>
|
||||
<template v-if="preview?.product_scope">
|
||||
<p>候选产品:{{ preview.product_scope.candidate_products }},尚未关联档案:{{ preview.product_scope.without_product_link }},身份风险:{{ preview.product_scope.identity_warnings }}</p>
|
||||
<p>{{ preview.product_scope.note }}</p>
|
||||
<el-table v-if="preview.product_scope.examples.length" :data="preview.product_scope.examples" size="small" max-height="240">
|
||||
<el-table-column prop="source_id" label="来源产品标识" width="150" />
|
||||
<el-table-column label="待核对原因"><template #default="{ row }">{{ reasonText(row.reason) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<p v-if="preview.product_scope.truncated">仅展示前 100 条身份风险。</p>
|
||||
</template>
|
||||
<p v-if="latest">最近任务:{{ statuses[latest.status] ?? latest.status }} · {{ latest.run_no }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.business-sync { padding: 16px 0; border-top: 1px solid var(--ys-line); }
|
||||
.heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.heading h2 { margin: 0; font-size: 16px; }
|
||||
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 12px; }
|
||||
.controls .el-button + .el-button { margin-left: 0; }
|
||||
.business-sync p { color: var(--ys-ink-2); font-size: 13px; }
|
||||
</style>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import * as api from '@/api/tplus-workbench'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const open = defineModel<boolean>({ required: true })
|
||||
const emit = defineEmits<{ saved: [] }>()
|
||||
const user = useUserStore(), saving = ref(false)
|
||||
const ships = ref<api.SourceChoice[]>([]), receipts = ref<api.SourceChoice[]>([])
|
||||
const groups = ref<api.Fact[]>([])
|
||||
const form = reactive({ negative_shipment_id: '', positive_shipment_id: '', receipt_ids: [] as string[], target_group_key: '', evidence: '' })
|
||||
async function searchShips(search = ''): Promise<void> {
|
||||
try { ships.value = (await api.sourceChoices('shipments', search)).list } catch (e) { handledError(e) }
|
||||
}
|
||||
async function searchReceipts(search = ''): Promise<void> {
|
||||
try { receipts.value = (await api.sourceChoices('receipts', search)).list } catch (e) { handledError(e) }
|
||||
}
|
||||
watch(open, async value => {
|
||||
if (!value) return
|
||||
Object.assign(form, { negative_shipment_id: '', positive_shipment_id: '', receipt_ids: [], target_group_key: '', evidence: '' })
|
||||
try { groups.value = (await api.listFacts({ kind: 'GROUP', pageSize: 200 })).list } catch (e) { handledError(e) }
|
||||
await Promise.all([searchShips(), searchReceipts()])
|
||||
})
|
||||
async function save(): Promise<void> {
|
||||
if (saving.value || !user.can('tplus:facts')) return
|
||||
if (!form.negative_shipment_id || !form.positive_shipment_id || !form.target_group_key || !form.evidence.trim()) {
|
||||
ElMessage.warning('请选择正负更正单、原业务平账组,并填写处理依据'); return
|
||||
}
|
||||
saving.value = true
|
||||
try { await api.saveCorrectionPair(form); ElMessage.success('更正单已成对登记,实际回款按原到账日归入原业务'); open.value = false; emit('saved') }
|
||||
catch (e) { handledError(e) } finally { saving.value = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="open" title="转客户更正单处理" width="min(720px, 94vw)" :close-on-click-modal="!saving" :show-close="!saving">
|
||||
<el-alert title="原单保留,正负更正单成对排除绩效。实际回款保留唯一身份和到账日,归入原业务平账。" type="info" :closable="false" />
|
||||
<el-form label-position="top" class="pair-form" :disabled="saving || !user.can('tplus:facts')" @submit.prevent="save">
|
||||
<el-form-item label="原客户的负数更正单">
|
||||
<el-select v-model="form.negative_shipment_id" filterable remote :remote-method="searchShips" placeholder="按 T+ 单号搜索" aria-label="负数更正单">
|
||||
<el-option v-for="r in ships" :key="r.source_id" :label="`${r.source_no ?? r.source_id} · ${r.date}`" :value="r.source_id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="新客户对应的正数更正单">
|
||||
<el-select v-model="form.positive_shipment_id" filterable remote :remote-method="searchShips" placeholder="选择金额相抵的正数单" aria-label="正数更正单">
|
||||
<el-option v-for="r in ships" :key="r.source_id" :label="`${r.source_no ?? r.source_id} · ${r.date}`" :value="r.source_id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="归入原客户的平账组">
|
||||
<el-select v-model="form.target_group_key" filterable aria-label="原业务平账组" placeholder="选择原客户已登记的平账组">
|
||||
<el-option v-for="g in groups" :key="g.fact_key" :value="g.fact_key" :label="`${g.fact_key} · 客户 ${g.data.customer_id}`" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="新客户的实际回款(可多选,尚未到账可不选)">
|
||||
<el-select v-model="form.receipt_ids" multiple filterable remote :remote-method="searchReceipts" aria-label="实际回款" placeholder="按 T+ 收款单号搜索">
|
||||
<el-option v-for="r in receipts" :key="r.source_id" :label="`${r.source_no ?? r.source_id} · ${r.date}`" :value="r.source_id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="处理依据与原因">
|
||||
<el-input v-model="form.evidence" type="textarea" :rows="3" maxlength="500" show-word-limit aria-label="更正处理依据" />
|
||||
</el-form-item>
|
||||
<p class="page-desc">保存后重新预览并导入原业务。已正式导入的单据不能在这里转移;已生成工资的差额通过当月调整单处理。</p>
|
||||
</el-form>
|
||||
<template #footer><el-button :disabled="saving" @click="open = false">取消</el-button><el-button type="primary" :loading="saving" :disabled="!user.can('tplus:facts')" @click="save">确认成对处理</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pair-form { margin-top: 16px; }
|
||||
.el-select { width: 100%; }
|
||||
</style>
|
||||
@@ -32,7 +32,7 @@ async function submit(): Promise<void> {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-dialog v-model="visible" title="新建 T+ 数据任务" width="min(560px, 94vw)" :close-on-click-modal="!pending" :show-close="!pending">
|
||||
<el-dialog v-model="visible" title="新建 T+ 数据任务" width="min(560px, 94vw)" :close-on-click-modal="!pending" :close-on-press-escape="!pending" :show-close="!pending">
|
||||
<el-form label-position="top" :disabled="pending">
|
||||
<el-form-item label="任务内容">
|
||||
<el-radio-group v-model="taskType">
|
||||
@@ -40,7 +40,8 @@ async function submit(): Promise<void> {
|
||||
<el-radio-button value="AUDIT">关联检查</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="taskType === 'READ'" label="读取范围">
|
||||
<el-form-item v-if="taskType === 'READ'">
|
||||
<template #label><span>读取范围</span><span class="selection-count">已选 {{ selected.length }} 项</span></template>
|
||||
<el-checkbox-group v-model="selected" class="entity-options">
|
||||
<el-checkbox v-for="item in entities" :key="item.value" :value="item.value">{{ item.label }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
@@ -55,6 +56,9 @@ async function submit(): Promise<void> {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.entity-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; }
|
||||
.task-note { color: var(--ys-ink-2); line-height: 1.7; }
|
||||
.entity-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; width: 100%; }
|
||||
.entity-options :deep(.el-checkbox) { margin: 0; min-height: 40px; height: auto; padding: 8px 10px; border: 1px solid var(--ys-line); border-radius: var(--ys-radius-sm); }
|
||||
.entity-options :deep(.el-checkbox.is-checked) { border-color: var(--el-color-primary-light-7); background: var(--ys-indigo-wash); }
|
||||
.selection-count { margin-left: auto; color: var(--ys-ink-3); font-size: 12px; }
|
||||
.task-note { margin: 16px 0 0; padding-top: 16px; border-top: 1px solid var(--ys-line-soft); color: var(--ys-ink-2); font-size: 13px; line-height: 1.7; }
|
||||
</style>
|
||||
@@ -18,6 +18,7 @@ const kind = ref<api.FactKind>('PERSONNEL')
|
||||
const key = ref(''), evidence = ref(''), version = ref(0), saving = ref(false)
|
||||
const d = reactive<Record<string, unknown>>({})
|
||||
const shipments = ref(''), receipts = ref(''), excluded = ref('')
|
||||
const roleHistory = ref<api.RolePeriod[]>([])
|
||||
const options = ref<api.SourceChoice[]>([])
|
||||
const sourceEntity: Partial<Record<api.FactKind, string>> = {PERSONNEL: 'personnel', PRODUCT: 'products', CUSTOMER: 'customers', RECEIPT: 'receipts', SHIPMENT: 'shipment_items', CORRECTION: 'receipts'}
|
||||
const ids = (value: string): string[] => value.split(/[\s,,]+/).map(v => v.trim()).filter(Boolean)
|
||||
@@ -26,7 +27,7 @@ async function search(value = ''): Promise<void> {
|
||||
if (!entity) { options.value = []; return }
|
||||
try { options.value = (await api.sourceChoices(entity, value)).list } catch (e) { handledError(e) }
|
||||
}
|
||||
function resetData(): void { for (const field of Object.keys(d)) delete d[field]; key.value = ''; version.value = 0; evidence.value = ''; shipments.value = ''; receipts.value = ''; excluded.value = ''; void search() }
|
||||
function resetData(): void { for (const field of Object.keys(d)) delete d[field]; key.value = ''; version.value = 0; evidence.value = ''; shipments.value = ''; receipts.value = ''; excluded.value = ''; roleHistory.value = []; void search() }
|
||||
watch(kind, () => { if (!props.fact) resetData() })
|
||||
watch(open, value => {
|
||||
if (!value) return
|
||||
@@ -34,6 +35,7 @@ watch(open, value => {
|
||||
if (props.fact) {
|
||||
kind.value = props.fact.kind; key.value = props.fact.fact_key; version.value = props.fact.version
|
||||
evidence.value = props.fact.evidence; Object.assign(d, props.fact.data)
|
||||
roleHistory.value = ((d.role_history as api.RolePeriod[] | undefined) ?? []).map(period => ({...period}))
|
||||
shipments.value = (d.shipment_ids as string[] | undefined)?.join(',') ?? ''
|
||||
receipts.value = (d.receipt_ids as string[] | undefined)?.join(',') ?? ''
|
||||
excluded.value = (d.excluded_receipt_ids as string[] | undefined)?.join(',') ?? ''
|
||||
@@ -47,12 +49,19 @@ function chosen(value: string): void {
|
||||
}
|
||||
async function save(): Promise<void> {
|
||||
if (saving.value) return
|
||||
if (kind.value === 'CORRECTION') { ElMessage.warning('请使用工作区的更正单成对处理入口,历史资料仅可查看'); return }
|
||||
if (!key.value || !evidence.value.trim()) { ElMessage.warning('请填写资料标识和确认依据'); return }
|
||||
const data = {...d}
|
||||
if (kind.value === 'GUIDANCE' && d.basis === 'UNIT') delete data.shipment_line_id
|
||||
if (kind.value === 'GROUP') data.guidance_month_basis = 'SHIPMENT'
|
||||
if (kind.value === 'GROUP' && !d.prepayment_parameter_basis) data.prepayment_parameter_basis = null
|
||||
if (kind.value === 'PERSONNEL') { data.code = key.value; data.status = d.status ?? 1 }
|
||||
if (kind.value === 'PERSONNEL') {
|
||||
if (roleHistory.value.some(period => !period.start_month || !period.end_month || period.end_month < period.start_month)) {
|
||||
ElMessage.warning('请填写完整的历史岗位月份,结束月份须不早于开始月份'); return
|
||||
}
|
||||
data.code = key.value; data.status = d.status ?? 1
|
||||
data.role_history = roleHistory.value.map(period => ({...period}))
|
||||
}
|
||||
if (kind.value === 'GROUP') {
|
||||
Object.assign(data, {shipment_ids: ids(shipments.value), receipt_ids: ids(receipts.value), excluded_receipt_ids: ids(excluded.value),
|
||||
scope_confirmed: Boolean(d.scope_confirmed), opening_confirmed: Boolean(d.opening_confirmed),
|
||||
@@ -71,14 +80,27 @@ async function save(): Promise<void> {
|
||||
<template>
|
||||
<el-dialog v-model="open" title="核算补充资料" width="min(720px, 94vw)">
|
||||
<el-alert title="只登记已核对的事实;资料不齐全时请先保留空白,核对结果会列出待补项目。" type="info" :closable="false" />
|
||||
<el-alert v-if="kind === 'CORRECTION'" title="此处只查看历史更正依据;新增或补选实际回款请使用补充资料页的更正单成对处理入口。" type="info" :closable="false" />
|
||||
<el-alert v-if="!permissionUser.can('tplus:facts')" title="当前为只读资料,可查看已保存内容;修改需管理员授权。" type="info" :closable="false" />
|
||||
<el-form label-position="top" class="fact-form" :disabled="!permissionUser.can('tplus:facts')" @submit.prevent="save">
|
||||
<el-form-item label="资料类型"><el-select v-model="kind" :disabled="!!fact" aria-label="资料类型"><el-option v-for="(label, value) in factKinds" :key="value" :label="label" :value="value" /></el-select></el-form-item>
|
||||
<el-form label-position="top" class="fact-form" :disabled="!permissionUser.can('tplus:facts') || kind === 'CORRECTION'" @submit.prevent="save">
|
||||
<el-form-item label="资料类型"><el-select v-model="kind" :disabled="!!fact" aria-label="资料类型"><el-option v-for="(label, value) in factKinds" :key="value" :label="label" :value="value" :disabled="value === 'CORRECTION' && !fact" /></el-select></el-form-item>
|
||||
<el-form-item v-if="sourceEntity[kind] && !fact" label="从已读取的源数据选择"><el-select filterable remote :remote-method="search" placeholder="按T+编号搜索;最多显示200条" aria-label="选择源数据" @change="chosen"><el-option v-for="item in options" :key="item.source_id" :value="item.source_id" :label="`${item.source_no ?? item.source_id} ${item.name} ${item.date}`" /></el-select></el-form-item>
|
||||
<el-form-item :label="kind === 'PERSONNEL' ? '人员工号(保留前导零)' : ['PRODUCT', 'CUSTOMER', 'RECEIPT', 'SHIPMENT', 'CORRECTION'].includes(kind) ? '对应 T+ 记录标识' : '资料标识(英文、数字或连字符)'"><el-input v-model="key" :disabled="!!fact" aria-label="资料标识" /></el-form-item>
|
||||
<template v-if="kind === 'PERSONNEL'">
|
||||
<el-form-item label="姓名"><el-input v-model="d.name" aria-label="姓名" /></el-form-item>
|
||||
<el-form-item label="岗位"><el-select v-model="d.role_code" aria-label="岗位"><el-option label="业务员" value="salesperson" /><el-option label="经理" value="sales_manager" /></el-select></el-form-item>
|
||||
<el-form-item label="当前岗位"><el-select v-model="d.role_code" aria-label="岗位"><el-option label="业务员" value="salesperson" /><el-option label="经理" value="sales_manager" /></el-select></el-form-item>
|
||||
<el-form-item label="已确认的历史岗位">
|
||||
<div class="role-history">
|
||||
<p class="page-desc">按客户确认的月份登记,起止月份均包含在内。核算会按业务日期取对应岗位。</p>
|
||||
<div v-for="(period, index) in roleHistory" :key="index" class="role-period">
|
||||
<el-date-picker v-model="period.start_month" type="month" value-format="YYYY-MM" placeholder="开始月份" :aria-label="`历史岗位开始月份 ${index + 1}`" :clearable="false" />
|
||||
<el-date-picker v-model="period.end_month" type="month" value-format="YYYY-MM" placeholder="结束月份" :aria-label="`历史岗位结束月份 ${index + 1}`" :clearable="false" />
|
||||
<el-select v-model="period.role_code" :aria-label="`历史岗位 ${index + 1}`"><el-option label="业务员" value="salesperson" /><el-option label="经理" value="sales_manager" /></el-select>
|
||||
<el-button :aria-label="`移除历史岗位 ${index + 1}`" @click="roleHistory.splice(index, 1)">移除</el-button>
|
||||
</div>
|
||||
<el-button @click="roleHistory.push({start_month: '', end_month: '', role_code: 'sales_manager'})">添加历史岗位</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="在职状态"><el-select v-model="d.status" aria-label="在职状态"><el-option label="在职" :value="1" /><el-option label="离职" :value="0" /></el-select></el-form-item>
|
||||
</template>
|
||||
<template v-if="kind === 'PRODUCT'">
|
||||
@@ -87,7 +109,7 @@ async function save(): Promise<void> {
|
||||
</template>
|
||||
<template v-if="kind === 'CUSTOMER'">
|
||||
<el-form-item label="绩效客户分类"><el-select v-model="d.customer_type" aria-label="绩效客户分类"><el-option label="个人户" value="personal" /><el-option label="经理个人户" value="manager_personal" /><el-option label="企业户" value="enterprise" /></el-select></el-form-item>
|
||||
<el-form-item label="客户公司负责人姓名"><el-input v-model="d.owner_name" aria-label="客户公司负责人" /></el-form-item>
|
||||
<el-form-item label="客户公司负责人姓名(选填)"><el-input v-model="d.owner_name" aria-label="客户公司负责人" placeholder="已核实后填写,未知可留空" /></el-form-item>
|
||||
</template>
|
||||
<template v-if="kind === 'GROUP'">
|
||||
<el-form-item label="T+ 客户记录标识"><el-input v-model="d.customer_id" aria-label="T+ 客户记录标识" /></el-form-item>
|
||||
@@ -137,11 +159,15 @@ async function save(): Promise<void> {
|
||||
<el-form-item label="确认依据与修改原因(必填)"><el-input v-model="evidence" type="textarea" :rows="3" maxlength="500" aria-label="确认依据与修改原因" placeholder="记录确认人、日期、例单或更正原因;不要填写账号密码" /></el-form-item>
|
||||
<p v-if="fact" class="page-desc">正在编辑第 {{ version }} 版,保存后新增版本,旧内容保留。</p>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="open = false">取消</el-button><el-button type="primary" :loading="saving" @click="save" :disabled="!permissionUser.can('tplus:facts')" :title="permissionUser.permissionReason('tplus:facts')">保存资料</el-button></template>
|
||||
<template #footer><el-button @click="open = false">取消</el-button><el-button type="primary" :loading="saving" @click="save" :disabled="!permissionUser.can('tplus:facts') || kind === 'CORRECTION'" :title="permissionUser.permissionReason('tplus:facts')">保存资料</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fact-form { margin-top: 16px; } .checks { display: grid; gap: 8px; margin-bottom: 16px; }
|
||||
.el-select, .el-date-editor { width: 100%; } .el-checkbox { white-space: normal; height: auto; line-height: 1.6; }
|
||||
.role-history { width: 100%; display: grid; gap: 12px; }
|
||||
.role-period { display: grid; grid-template-columns: 1fr 1fr 1fr auto; gap: 8px; }
|
||||
.role-period > * { min-width: 0; }
|
||||
@media (max-width: 767px) { .role-period { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
</style>
|
||||
@@ -1,14 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Check } from '@element-plus/icons-vue'
|
||||
import type { TPlusBatch } from '@/api/tplus'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { entities, statuses } from './labels'
|
||||
|
||||
const props = withDefaults(defineProps<{ batch: TPlusBatch | null; compact?: boolean }>(), {compact: false})
|
||||
const props = withDefaults(defineProps<{ batch: TPlusBatch | null; compact?: boolean; stale?: boolean }>(), {compact: false, stale: false})
|
||||
const emit = defineEmits<{ detail: [number: string] }>()
|
||||
const running = computed(() => props.batch?.status === 'RUNNING')
|
||||
const progress = computed(() => props.batch?.progress)
|
||||
const percentage = computed(() => progress.value?.percentage ?? null)
|
||||
const percentage = computed(() => {
|
||||
const value = progress.value?.percentage
|
||||
return value == null || !Number.isFinite(value) ? null : Math.min(100, Math.max(0, value))
|
||||
})
|
||||
const active = computed(() => !!props.batch && ['RUNNING', 'QUEUED'].includes(props.batch.status) && !props.stale)
|
||||
const ended = computed(() => !!props.batch && ['SUCCEEDED', 'FAILED', 'PARTIAL'].includes(props.batch.status))
|
||||
const audit = computed(() => props.batch?.task_type === 'AUDIT')
|
||||
const hasErrors = computed(() => !!props.batch && ['FAILED', 'PARTIAL'].includes(props.batch.status))
|
||||
const title = computed(() => {
|
||||
const batch = props.batch
|
||||
if (!batch) return '尚未开始同步'
|
||||
@@ -21,68 +29,117 @@ const title = computed(() => {
|
||||
})
|
||||
const category = computed(() => entities.find(item => item.value === progress.value?.active_entity)?.label)
|
||||
const elapsed = computed(() => {
|
||||
const seconds = progress.value?.elapsed_seconds ?? 0
|
||||
const seconds = progress.value?.elapsed_seconds
|
||||
if (seconds == null) return '未记录'
|
||||
if (seconds < 60) return `${seconds} 秒`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
return minutes < 60 ? `${minutes} 分 ${seconds % 60} 秒` : `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分`
|
||||
})
|
||||
const numberText = (value: number | null | undefined): string => value == null ? '—' : value.toLocaleString()
|
||||
const entityState: Record<string, string> = {WAITING: '等待', READING: '读取中', COMPLETED: '已读取', FAILED: '异常'}
|
||||
const primaryValue = computed(() => {
|
||||
if (props.batch?.status === 'QUEUED') return '等待执行'
|
||||
if (percentage.value != null) return `${percentage.value.toFixed(1).replace(/\.0$/, '')}%`
|
||||
if (audit.value) return ended.value ? hasErrors.value ? '核验有异常' : '核验完成' : '核验中'
|
||||
return numberText(props.batch?.source_record_count)
|
||||
})
|
||||
const readingLabel = computed(() => {
|
||||
if (props.batch?.status === 'QUEUED') return '任务已排队,等待读取服务执行'
|
||||
if (percentage.value != null) return `${numberText(props.batch?.source_record_count)} / ${numberText(progress.value?.total_records)} 条`
|
||||
if (audit.value) return ended.value ? '核验结果请查看任务详情' : '核对单据关联,暂不提供可计算的总量'
|
||||
return ended.value ? '条已处理' : '条已处理,总量尚未取得'
|
||||
})
|
||||
const context = computed(() => props.stale ? '状态更新中断,当前显示为上次结果'
|
||||
: props.batch?.status === 'QUEUED' ? '读取服务会依次处理队列'
|
||||
: running.value ? progress.value?.phase === 'COUNTING' ? '正在统计读取总量' : audit.value ? '正在核对收付款单据与订单、销货单的关联' : category.value ? `当前读取:${category.value}` : '任务正在执行'
|
||||
: props.batch?.status === 'SUCCEEDED' ? audit.value ? '关联检查已结束,可在详情中核对结果' : '可前往资料与导入继续核对'
|
||||
: '本次任务已结束,请查看详情核对异常')
|
||||
const stageIndex = computed(() => props.batch?.status === 'QUEUED' ? 0 : running.value ? 1 : 2)
|
||||
const stages = computed(() => ['排队', audit.value ? '关联核验' : '读取数据', hasErrors.value ? '异常结束' : '处理结束'])
|
||||
function stageState(index: number): string {
|
||||
if (index < stageIndex.value || index === 2 && props.batch?.status === 'SUCCEEDED') return 'done'
|
||||
if (index === stageIndex.value) return hasErrors.value ? 'error' : props.stale ? 'paused' : 'current'
|
||||
return 'waiting'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="sync-progress" :class="{'is-compact': compact}" aria-label="同步进度">
|
||||
<div class="progress-heading"><div><p v-if="!compact" class="section-caption">{{ running ? '当前同步' : '最近一次同步' }}</p><h2>{{ title }}</h2></div><span v-if="batch" class="task-status" :data-status="batch.status">{{ statuses[batch.status] }}</span></div>
|
||||
<section class="sync-progress" :class="{'is-compact': compact, 'is-complete': batch?.status === 'SUCCEEDED', 'has-error': hasErrors, 'is-stale': stale}" aria-label="同步进度">
|
||||
<div class="progress-heading"><h2>{{ title }}</h2><span v-if="batch" class="task-status" :data-status="stale ? 'STALE' : batch.status">{{ stale ? '待更新' : statuses[batch.status] }}</span></div>
|
||||
<template v-if="batch">
|
||||
<div class="progress-reading"><strong>{{ percentage == null ? numberText(batch.source_record_count) : `${percentage.toFixed(1).replace(/\.0$/, '')}%` }}</strong><span>{{ percentage == null ? '条已处理' : `${numberText(batch.source_record_count)} / ${numberText(progress?.total_records)} 条` }}</span></div>
|
||||
<div class="meter-wrap" :class="{'is-indeterminate': percentage == null && ['RUNNING','QUEUED'].includes(batch.status)}"><progress class="sync-meter" :class="{'is-error': ['FAILED', 'PARTIAL'].includes(batch.status)}" :value="percentage ?? undefined" max="100" :aria-label="`同步进度:${percentage == null ? `已处理${batch.source_record_count}条,总量尚未取得` : `${percentage}%`}`" /></div>
|
||||
<div class="progress-context"><span>{{ running && category ? `当前读取:${category}` : running && percentage == null ? '任务正在执行,总量暂不可用' : batch.status === 'QUEUED' ? '读取服务会依次处理队列' : batch.status === 'SUCCEEDED' ? batch.task_type === 'AUDIT' ? '可在详情中查看检查结果' : '可前往资料与导入继续核对' : '查看任务详情了解处理结果' }}</span><span>已用时 {{ elapsed }}</span></div>
|
||||
<div v-if="!compact" class="result-counts"><span>新增 <b>{{ numberText(batch.staged_record_count) }}</b></span><span>重复跳过 <b>{{ numberText(batch.skipped_record_count) }}</b></span><span :class="{'has-errors': batch.failed_record_count > 0}">异常 <b>{{ numberText(batch.failed_record_count) }}</b></span></div>
|
||||
<div class="progress-body" :class="{'without-counts': compact || audit || batch.status === 'QUEUED'}">
|
||||
<div class="progress-main">
|
||||
<div class="progress-reading"><strong :class="{'text-reading': percentage == null && (audit || batch.status === 'QUEUED')}">{{ primaryValue }}</strong><span>{{ readingLabel }}</span></div>
|
||||
<div class="sync-meter" :class="{'is-indeterminate': percentage == null}" role="progressbar" aria-label="同步进度" :aria-valuenow="percentage ?? undefined" aria-valuemin="0" aria-valuemax="100" :aria-valuetext="`${title},${primaryValue},${readingLabel}`">
|
||||
<span v-if="percentage != null" class="meter-fill" :style="{ transform: `scaleX(${percentage / 100})` }"></span>
|
||||
</div>
|
||||
<div class="progress-context"><span class="context-copy"><span v-if="active" class="execution-signal" aria-hidden="true"><i></i><i></i><i></i></span>{{ context }}</span><span>已用时 {{ elapsed }}</span></div>
|
||||
</div>
|
||||
<dl v-if="!compact && !audit && batch.status !== 'QUEUED'" class="result-counts"><div><dt>新增记录</dt><dd>{{ numberText(batch.staged_record_count) }}</dd></div><div><dt>重复跳过</dt><dd>{{ numberText(batch.skipped_record_count) }}</dd></div><div :class="{'has-errors': batch.failed_record_count > 0}"><dt>异常记录</dt><dd>{{ numberText(batch.failed_record_count) }}</dd></div></dl>
|
||||
</div>
|
||||
<ol v-if="!compact" class="task-stages" aria-label="任务阶段"><li v-for="(label, index) in stages" :key="index" :data-state="stageState(index)"><span class="stage-mark"><el-icon v-if="stageState(index) === 'done'" aria-hidden="true"><Check /></el-icon><template v-else>{{ index + 1 }}</template></span><span>{{ label }}</span></li></ol>
|
||||
<ul v-if="!compact && progress?.entities.length" class="scope-progress" aria-label="分类读取进度"><li v-for="item in progress.entities" :key="item.entity_type" :data-state="item.status"><span>{{ entities.find(entity => entity.value === item.entity_type)?.label ?? item.entity_type }}</span><small>{{ entityState[item.status] ?? '等待' }}</small></li></ul>
|
||||
<div v-if="!compact" class="progress-footer"><span class="batch-ref" :title="batch.batch_no">{{ batch.batch_no }}</span><button class="detail-link" type="button" @click="emit('detail', batch.batch_no)">查看详情</button></div>
|
||||
<p v-if="!compact" class="updated">{{ running ? '进度每 5 秒更新' : `完成时间:${formatDateTime(batch.finished_at)}` }}</p>
|
||||
<div v-if="!compact" class="progress-footer"><div class="batch-reference"><span class="batch-ref">{{ batch.batch_no }}</span><span class="updated">{{ stale ? '等待状态恢复' : active ? '进度每 5 秒更新' : batch.status === 'QUEUED' ? '尚未开始' : `结束时间:${formatDateTime(batch.finished_at, '未记录')}` }}</span></div><button class="detail-link" type="button" @click="emit('detail', batch.batch_no)">查看详情</button></div>
|
||||
</template>
|
||||
<p v-else class="empty-note">连接就绪后,点击“立即同步”读取 T+ 数据;也可以开启每周自动采集。</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sync-progress { container-type: inline-size; }
|
||||
.progress-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; }
|
||||
.section-caption { margin: 0 0 6px; font-size: 13px; color: var(--ys-ink-2); }
|
||||
h2 { font-size: 18px; font-weight: 600; margin: 0; }
|
||||
.task-status { font-size: 13px; color: var(--ys-ink-2); white-space: nowrap; }
|
||||
h2 { font-size: 16px; font-weight: 600; margin: 0; line-height: 1.6; }
|
||||
.task-status { padding: 3px 9px; border: 1px solid var(--ys-line); border-radius: 5px; font-size: 12px; color: var(--ys-ink-2); white-space: nowrap; background: var(--ys-mist); }
|
||||
.task-status[data-status='RUNNING'] { color: var(--ys-indigo); }
|
||||
.task-status[data-status='SUCCEEDED'] { color: var(--el-color-success-dark-2); }
|
||||
.task-status[data-status='SUCCEEDED'] { color: #18764f; background: var(--ys-ok-wash); border-color: var(--el-color-success-light-8); }
|
||||
.task-status[data-status='FAILED'], .has-errors { color: var(--el-color-danger-dark-2); }
|
||||
.progress-reading { display: flex; align-items: baseline; flex-wrap: wrap; gap: 12px; margin: 22px 0 12px; font-variant-numeric: tabular-nums; }
|
||||
.progress-reading strong { font-size: 32px; font-weight: 500; line-height: 1.2; color: var(--ys-indigo); }
|
||||
.progress-reading span, .progress-context { font-size: 13px; color: var(--ys-ink-2); }
|
||||
.meter-wrap { position: relative; overflow: hidden; border-radius: 4px; }
|
||||
.sync-meter { display: block; appearance: none; border: 0; width: 100%; height: 8px; border-radius: 4px; overflow: hidden; accent-color: var(--ys-indigo); background: var(--ys-mist); }
|
||||
.sync-meter::-webkit-progress-bar { background: var(--ys-mist); border-radius: 4px; }
|
||||
.sync-meter::-webkit-progress-value { background: var(--ys-indigo); border-radius: 4px; transition: width 200ms ease; }
|
||||
.sync-meter.is-error::-webkit-progress-value { background: var(--el-color-danger); }
|
||||
.meter-wrap.is-indeterminate::after { content: ''; position: absolute; inset: 0 auto 0 0; width: 26%; border-radius: 4px; background: var(--ys-indigo); animation: sync-reading 2s ease-in-out infinite alternate; }
|
||||
.progress-context { display: flex; justify-content: space-between; gap: 12px; margin-top: 12px; flex-wrap: wrap; }
|
||||
.result-counts { display: flex; gap: 24px; flex-wrap: wrap; margin: 20px 0 16px; font-size: 13px; color: var(--ys-ink-2); }
|
||||
.result-counts b { margin-left: 6px; font-weight: 500; color: var(--ys-ink); font-variant-numeric: tabular-nums; }
|
||||
.result-counts .has-errors b { color: var(--el-color-danger-dark-2); }
|
||||
.scope-progress { display: flex; gap: 8px 18px; flex-wrap: wrap; list-style: none; padding: 0; margin: 0; }
|
||||
.scope-progress li { display: flex; gap: 6px; font-size: 13px; color: var(--ys-ink-2); }
|
||||
.task-status[data-status='PARTIAL'] { color: #895d12; background: var(--ys-warn-wash); }
|
||||
.progress-body { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(220px, 0.75fr); gap: 24px; align-items: center; margin: 22px 0; }
|
||||
.progress-main { min-width: 0; }
|
||||
.progress-body.without-counts { grid-template-columns: minmax(0, 1fr); }
|
||||
.progress-reading { display: flex; align-items: baseline; flex-wrap: wrap; gap: 8px 12px; margin-bottom: 14px; font-variant-numeric: tabular-nums; }
|
||||
.progress-reading strong { font-size: 32px; font-weight: 600; line-height: 1.2; color: var(--ys-indigo); }
|
||||
.progress-reading .text-reading { font-size: 24px; color: var(--ys-ink); }
|
||||
.progress-reading span { font-size: 12px; color: var(--ys-ink-2); line-height: 1.7; }
|
||||
.sync-meter { position: relative; width: 100%; height: 10px; border-radius: 5px; overflow: hidden; background: var(--ys-mist); }
|
||||
.meter-fill { display: block; width: 100%; height: 100%; transform-origin: left; background: var(--ys-indigo); border-radius: inherit; transition: transform 360ms cubic-bezier(0.16, 1, 0.3, 1); }
|
||||
.is-complete .meter-fill { background: var(--ys-ok); }
|
||||
.has-error .meter-fill { background: var(--ys-danger); }
|
||||
.sync-meter.is-indeterminate { background: repeating-linear-gradient(120deg, var(--ys-indigo-wash) 0 8px, var(--ys-mist) 8px 16px); }
|
||||
.progress-context { display: flex; align-items: baseline; justify-content: space-between; gap: 8px 16px; margin-top: 12px; flex-wrap: wrap; color: var(--ys-ink-2); font-size: 12px; line-height: 1.7; }
|
||||
.context-copy { display: inline-flex; gap: 8px; align-items: baseline; min-width: 0; }
|
||||
.execution-signal { display: inline-flex; flex: none; align-self: center; gap: 3px; }
|
||||
.execution-signal i { width: 4px; height: 4px; border-radius: 50%; background: var(--ys-indigo); animation: sync-execution 1400ms ease-in-out infinite; }
|
||||
.execution-signal i:nth-child(2) { animation-delay: 160ms; }
|
||||
.execution-signal i:nth-child(3) { animation-delay: 320ms; }
|
||||
.result-counts { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; padding-left: 20px; margin: 0; border-left: 1px solid var(--ys-line); }
|
||||
.result-counts dt { font-size: 12px; color: var(--ys-ink-2); line-height: 1.7; }
|
||||
.result-counts dd { margin: 8px 0 0; color: var(--ys-ink); font-size: 20px; font-weight: 500; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
||||
.result-counts .has-errors dd { color: var(--el-color-danger-dark-2); }
|
||||
.task-stages { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; list-style: none; padding: 0; margin: 20px 0 16px; }
|
||||
.task-stages li { display: flex; align-items: center; gap: 8px; padding-top: 10px; border-top: 2px solid var(--ys-line); font-size: 12px; color: var(--ys-ink-3); line-height: 1.6; }
|
||||
.stage-mark { display: inline-flex; flex: none; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 50%; background: var(--ys-mist); font-variant-numeric: tabular-nums; }
|
||||
.task-stages li[data-state='done'] { border-color: var(--ys-ok); color: #18764f; }
|
||||
.task-stages li[data-state='current'] { border-color: var(--ys-indigo); color: var(--ys-indigo); font-weight: 500; }
|
||||
.task-stages li[data-state='error'] { border-color: var(--ys-danger); color: var(--el-color-danger-dark-2); }
|
||||
.scope-progress { display: flex; gap: 8px; flex-wrap: wrap; list-style: none; padding: 0; margin: 0; }
|
||||
.scope-progress li { display: flex; gap: 6px; padding: 4px 8px; border: 1px solid var(--ys-line-soft); border-radius: 5px; font-size: 12px; color: var(--ys-ink-2); }
|
||||
.scope-progress small { font-size: inherit; }
|
||||
.scope-progress li[data-state='READING'] { color: var(--ys-indigo); }
|
||||
.scope-progress li[data-state='COMPLETED'] { color: var(--el-color-success-dark-2); }
|
||||
.scope-progress li[data-state='FAILED'] { color: var(--el-color-danger-dark-2); }
|
||||
.progress-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 18px; padding-top: 12px; border-top: 1px solid var(--ys-line); }
|
||||
.batch-ref { color: var(--ys-ink-2); font: 13px var(--ys-font-brand); font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.batch-reference { display: flex; flex-wrap: wrap; gap: 6px 20px; align-items: baseline; min-width: 0; }
|
||||
.batch-ref { color: var(--ys-ink-2); font-size: 12px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
|
||||
.detail-link { background: none; border: 0; color: var(--ys-indigo); cursor: pointer; font: inherit; font-size: 13px; padding: 6px 0; white-space: nowrap; }
|
||||
.detail-link:focus-visible { outline: 2px solid var(--ys-indigo); outline-offset: 3px; }
|
||||
.updated, .empty-note { color: var(--ys-ink-2); font-size: 13px; margin: 10px 0 0; }
|
||||
.updated, .empty-note { color: var(--ys-ink-2); font-size: 12px; line-height: 1.7; }
|
||||
.empty-note { padding: 28px 0; line-height: 1.8; font-size: 14px; }
|
||||
.is-compact { margin-bottom: 20px; }
|
||||
.is-compact .progress-reading { margin-top: 14px; }
|
||||
.is-compact .progress-body { grid-template-columns: minmax(0, 1fr); }
|
||||
.is-compact .progress-reading strong { font-size: 24px; }
|
||||
@keyframes sync-reading { to { transform: translateX(285%); } }
|
||||
@media (prefers-reduced-motion: reduce) { .meter-wrap.is-indeterminate::after { animation: none; } .sync-meter::-webkit-progress-value { transition: none; } }
|
||||
@keyframes sync-execution { 0%, 75%, 100% { opacity: 0.25; } 35% { opacity: 1; } }
|
||||
@container (max-width: 640px) { .progress-body { grid-template-columns: minmax(0, 1fr); gap: 16px; } .result-counts { padding: 14px 0 0; border-left: 0; border-top: 1px solid var(--ys-line-soft); } .result-counts dd { margin-top: 4px; } .progress-reading { margin-bottom: 12px; } .task-stages { gap: 10px; } .task-stages li { flex-wrap: wrap; gap: 6px; } }
|
||||
@media (prefers-reduced-motion: reduce) { .execution-signal i { animation: none; opacity: 0.65; } .meter-fill { transition: none; } }
|
||||
</style>
|
||||
|
||||
@@ -12,6 +12,7 @@ import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { handledError } from '@/utils/error'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import FactEditor from './FactEditor.vue'
|
||||
import CorrectionPairDialog from './CorrectionPairDialog.vue'
|
||||
import { actions, factKinds, reasonText, workKinds } from './workbench-labels'
|
||||
|
||||
const tab = ref('import'), entities = ref(['personnel']), idsText = ref('')
|
||||
@@ -19,8 +20,13 @@ const month = ref(''), groupText = ref(''), creating = ref(false), failed = ref(
|
||||
const runs = ref<api.WorkRun[]>([]), facts = ref<api.Fact[]>([]), total = ref(0), page = ref(1)
|
||||
const factsTotal = ref(0), factPage = ref(1), factKind = ref<string>(), loading = ref(false)
|
||||
const editing = ref(false), editedFact = ref<api.Fact | null>(null)
|
||||
const correctionOpen = ref(false)
|
||||
const current = ref<api.WorkRun | null>(null), lines = ref<api.WorkLine[]>([]), linePage = ref(1), linesTotal = ref(0)
|
||||
const details = ref(false), action = ref<string>(), history = ref<api.Fact[]>([]), historyOpen = ref(false)
|
||||
const linesLoading = ref(false), linesFailed = ref(false)
|
||||
const isMaster = computed(() => current.value?.kind === 'MASTER_PREVIEW' || current.value?.kind === 'MASTER_APPLY')
|
||||
const entityLabels: Record<string, string> = { personnel: '人员', products: '产品', customers: '客户' }
|
||||
const hasProducts = computed(() => lines.value.some(row => row.entity_type === 'products'))
|
||||
const requestKeys = new Map<string, string>()
|
||||
const split = (s: string): string[] => s.split(/[\s,,]+/).map(v => v.trim()).filter(Boolean)
|
||||
const counts = computed(() => (current.value?.summary?.counts ?? {}) as Record<string, number>)
|
||||
@@ -34,14 +40,14 @@ async function refresh(): Promise<void> {
|
||||
}
|
||||
async function queue(kind: api.WorkKind, preview?: api.WorkRun): Promise<void> {
|
||||
if (creating.value) return
|
||||
if (kind === 'MASTER_PREVIEW' && !entities.value.length) { ElMessage.warning('请选择基础数据'); return }
|
||||
if (kind === 'MASTER_PREVIEW' && !preview && !entities.value.length) { ElMessage.warning('请选择基础数据'); return }
|
||||
if (kind === 'CALC_PREVIEW' && (!month.value || !split(groupText.value).length)) { ElMessage.warning('请选择核算月份和平账组'); return }
|
||||
if ((kind === 'MASTER_APPLY' || kind === 'BUSINESS_APPLY')) {
|
||||
if (kind === 'MASTER_APPLY') {
|
||||
try { await ElMessageBox.confirm('将导入预览中已核实的数据。有冲突或需补充的记录会保留,导入不会直接生成工资。', '确认导入数据', {confirmButtonText: '确认导入', cancelButtonText: '返回核对', customClass: 'ys-confirm-box'}) } catch { return }
|
||||
}
|
||||
const input = (kind === 'MASTER_APPLY' || kind === 'BUSINESS_APPLY') ? {kind, preview_no: preview?.run_no}
|
||||
: kind === 'MASTER_PREVIEW' ? {kind, entity_types: entities.value, source_ids: split(idsText.value)}
|
||||
: {kind, month: month.value, group_keys: split(groupText.value)}
|
||||
const input = kind === 'MASTER_APPLY' ? {kind, preview_no: preview?.run_no}
|
||||
: kind === 'MASTER_PREVIEW' ? {kind, entity_types: (preview?.input.entity_types as string[] | undefined) ?? entities.value, source_ids: (preview?.input.source_ids as string[] | undefined) ?? split(idsText.value)}
|
||||
: {kind, month: (preview?.input.month as string | undefined) ?? month.value, group_keys: (preview?.input.group_keys as string[] | undefined) ?? split(groupText.value)}
|
||||
const intent = JSON.stringify(input), key = requestKeys.get(intent) ?? crypto.randomUUID()
|
||||
requestKeys.set(intent, key); creating.value = true
|
||||
try { await api.createWork({...input, request_key: key}); requestKeys.delete(intent); ElMessage.success('任务已排队,由读取服务处理'); await refresh() }
|
||||
@@ -49,8 +55,9 @@ async function queue(kind: api.WorkKind, preview?: api.WorkRun): Promise<void> {
|
||||
}
|
||||
async function loadLines(): Promise<void> {
|
||||
if (!current.value) return
|
||||
linesLoading.value = true; linesFailed.value = false; lines.value = []; linesTotal.value = 0
|
||||
try { const p = await api.workLines(current.value.run_no, {pageNum: linePage.value, pageSize: 20, action: action.value}); lines.value = p.list; linesTotal.value = p.total }
|
||||
catch (e) { handledError(e) }
|
||||
catch (e) { linesFailed.value = true; handledError(e) } finally { linesLoading.value = false }
|
||||
}
|
||||
async function show(run: api.WorkRun): Promise<void> {
|
||||
try { current.value = await api.getWork(run.run_no); linePage.value = 1; action.value = undefined; details.value = true; await loadLines() } catch (e) { handledError(e) }
|
||||
@@ -69,10 +76,27 @@ async function replay(): Promise<void> {
|
||||
try { await api.createWork({kind: 'CALC_PREVIEW', replay_no: current.value.run_no, request_key: crypto.randomUUID()}); ElMessage.success('已创建重新核对任务,使用原任务的资料和规则'); await refresh() }
|
||||
catch (e) { handledError(e) } finally { creating.value = false }
|
||||
}
|
||||
async function regeneratePreview(): Promise<void> {
|
||||
if (!current.value || creating.value) return
|
||||
try {
|
||||
const run = current.value
|
||||
const preview = run.kind.endsWith('_APPLY') ? await api.getWork(String(run.input.preview_no)) : run
|
||||
if (preview.kind === 'MASTER_PREVIEW' || preview.kind === 'BUSINESS_PREVIEW') await queue(preview.kind, preview)
|
||||
} catch (e) { handledError(e) }
|
||||
}
|
||||
function value(data: Record<string, unknown>, key: string): string { return data[key] == null ? '—' : String(data[key]) }
|
||||
function rowName(data: Record<string, unknown>): string {
|
||||
const v = data.values as Record<string, unknown> | undefined
|
||||
return String(v?.personnel_name ?? v?.product_name ?? v?.company_name ?? data.source_name ?? data.receipt_no ?? '—')
|
||||
return String(v?.personnel_name || v?.product_name || v?.company_name || data.source_name || data.receipt_no || (isMaster.value ? '名称未留存,请重新生成预览' : '—'))
|
||||
}
|
||||
function masterValue(data: Record<string, unknown>, key: string, fallback: string): string {
|
||||
const values = data.values as Record<string, unknown> | undefined
|
||||
return String(values?.[key] ?? data[fallback] ?? '—')
|
||||
}
|
||||
function lineReason(row: api.WorkLine): string {
|
||||
return reasonText(row.reason) || (isMaster.value && row.action === 'SKIP' ? '已导入且数据无变化,无需重复导入'
|
||||
: row.data.excluded_manager_own ? '经理本人业务,已排除绩效汇总'
|
||||
: row.data.manager_pending ? '经理分管关系待补,不生成经理工资' : '—')
|
||||
}
|
||||
onMounted(refresh)
|
||||
</script>
|
||||
@@ -85,7 +109,7 @@ onMounted(refresh)
|
||||
<el-tab-pane label="基础导入" name="import">
|
||||
<el-form label-position="top" class="create-form" @submit.prevent="queue('MASTER_PREVIEW')">
|
||||
<el-form-item label="本次预览的数据"><el-checkbox-group v-model="entities" aria-label="导入数据类型"><el-checkbox value="personnel">人员</el-checkbox><el-checkbox value="products">产品</el-checkbox><el-checkbox value="customers">客户</el-checkbox></el-checkbox-group></el-form-item>
|
||||
<el-form-item label="指定记录标识(选填,多个标识用逗号分隔;留空处理所选类型全部数据)"><el-input v-model="idsText" aria-label="指定导入范围" /></el-form-item>
|
||||
<el-form-item label="指定记录标识(选填)"><el-input v-model="idsText" aria-label="指定导入范围" /><p class="form-hint">多个标识用逗号分隔;留空处理所选类型全部数据。</p></el-form-item>
|
||||
<el-button type="primary" :loading="creating" @click="queue('MASTER_PREVIEW')" :disabled="!permissionUser.can('tplus:preview')" :title="permissionUser.permissionReason('tplus:preview')">生成导入预览</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
@@ -97,12 +121,12 @@ onMounted(refresh)
|
||||
<el-button type="primary" :loading="creating" @click="queue('CALC_PREVIEW')" :disabled="!permissionUser.can('tplus:preview')" :title="permissionUser.permissionReason('tplus:preview')">生成核算预演</el-button>
|
||||
</el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="正式业务导入" name="business-import">
|
||||
<el-alert title="预览核实后导入正式业务、发货和回款;生成工资需另行正式核算。" type="info" :closable="false" />
|
||||
<el-form class="create-form" label-position="top"><el-form-item label="资料核对月份"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" /></el-form-item><el-form-item label="已核实的独立业务平账组"><el-input v-model="groupText" placeholder="填写平账组标识,以逗号或换行分隔" /></el-form-item><el-button type="primary" :disabled="!permissionUser.can('tplus:preview')" :loading="creating" @click="queue('BUSINESS_PREVIEW')" :title="permissionUser.permissionReason('tplus:preview')">生成正式导入预览</el-button></el-form>
|
||||
<el-tab-pane label="旧业务预览" name="business-import">
|
||||
<el-alert title="旧规则预览仅供历史核对;业务请使用自动业务接入。" type="info" :closable="false" />
|
||||
<el-form class="create-form" label-position="top"><el-form-item label="资料核对月份"><el-date-picker v-model="month" type="month" value-format="YYYY-MM" /></el-form-item><el-form-item label="已核实的独立业务平账组"><el-input v-model="groupText" placeholder="填写平账组标识,以逗号或换行分隔" /></el-form-item><el-button type="primary" :disabled="!permissionUser.can('tplus:preview')" :loading="creating" @click="queue('BUSINESS_PREVIEW')" :title="permissionUser.permissionReason('tplus:preview')">生成旧规则预览</el-button></el-form>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="补充资料" name="facts">
|
||||
<div class="toolbar"><el-select v-model="factKind" clearable placeholder="全部资料" aria-label="筛选资料类型" @change="factPage = 1; refresh()"><el-option v-for="(label, key) in factKinds" :key="key" :value="key" :label="label" /></el-select><el-button type="primary" @click="edit(null)" :disabled="!permissionUser.can('tplus:facts')" :title="permissionUser.permissionReason('tplus:facts')">新增补充资料</el-button></div>
|
||||
<div class="toolbar"><el-select v-model="factKind" clearable placeholder="全部资料" aria-label="筛选资料类型" @change="factPage = 1; refresh()"><el-option v-for="(label, key) in factKinds" :key="key" :value="key" :label="label" /></el-select><el-button type="primary" @click="edit(null)" :disabled="!permissionUser.can('tplus:facts')" :title="permissionUser.permissionReason('tplus:facts')">新增补充资料</el-button><el-button @click="correctionOpen = true" :disabled="!permissionUser.can('tplus:facts')">更正单成对处理</el-button></div>
|
||||
<el-table v-loading="loading" :data="facts" empty-text="暂无补充资料,请根据已核实的业务情况填写。">
|
||||
<el-table-column label="资料类型" min-width="150"><template #default="{row}">{{ factKinds[row.kind as api.FactKind] }}</template></el-table-column><el-table-column prop="fact_key" label="标识" min-width="120" /><el-table-column prop="version" label="版本" width="70" /><el-table-column prop="evidence" label="确认依据" min-width="230" /><el-table-column label="操作" min-width="130"><template #default="{row}"><el-button link type="primary" @click="edit(row)">{{ permissionUser.can('tplus:facts') ? '查看 / 编辑' : '查看资料' }}</el-button><el-button link @click="showHistory(row)">历史</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
@@ -115,35 +139,54 @@ onMounted(refresh)
|
||||
<el-table-column label="状态" min-width="115"><template #default="{row}">{{ actions[row.status] ?? row.status }}</template></el-table-column>
|
||||
<el-table-column label="创建时间" min-width="160"><template #default="{row}">{{ formatDateTime(row.create_time) }}</template></el-table-column>
|
||||
<el-table-column label="异常原因" min-width="180"><template #default="{row}">{{ reasonText(row.error_code) || '—' }}</template></el-table-column>
|
||||
<el-table-column label="操作" min-width="170"><template #default="{row}"><el-button link type="primary" @click="show(row)">查看明细</el-button><el-button v-if="(row.kind === 'MASTER_PREVIEW' || row.kind === 'BUSINESS_PREVIEW') && ['SUCCEEDED','PARTIAL'].includes(row.status)" link type="primary" :disabled="!permissionUser.can('tplus:import') || (creating)" @click="queue(row.kind === 'BUSINESS_PREVIEW' ? 'BUSINESS_APPLY' : 'MASTER_APPLY', row)" :title="permissionUser.permissionReason('tplus:import')">执行导入</el-button></template></el-table-column>
|
||||
<el-table-column label="操作" min-width="170"><template #default="{row}"><el-button link type="primary" @click="show(row)">查看明细</el-button><el-button v-if="row.kind === 'MASTER_PREVIEW' && ['SUCCEEDED','PARTIAL'].includes(row.status)" link type="primary" :disabled="!permissionUser.can('tplus:import') || (creating)" @click="queue('MASTER_APPLY', row)" :title="permissionUser.permissionReason('tplus:import')">执行导入</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<AppPagination :total="total" :page-num="page" :page-size="10" @page-change="page = $event; refresh()" @size-change="refresh" />
|
||||
<CorrectionPairDialog v-model="correctionOpen" @saved="refresh" />
|
||||
<FactEditor v-model="editing" :fact="editedFact" @saved="refresh" />
|
||||
<el-dialog v-model="details" title="处理结果与逐笔依据" width="min(1240px, 96vw)">
|
||||
<template v-if="current">
|
||||
<el-alert title="本次用于数据导入或核对,不会直接生成月度工资。" type="info" :closable="false" />
|
||||
<p class="page-desc">{{ workKinds[current.kind] }} · {{ current.run_no }}</p>
|
||||
<el-alert :title="isMaster ? '本次处理人员、产品或客户档案。已存在且无变化、或不符合导入范围的记录会跳过,具体原因见下表。' : '本次用于数据导入或核对,不会直接生成月度工资。'" type="info" :closable="false" />
|
||||
<el-alert v-if="current.error_code" :title="reasonText(current.error_code)" type="warning" :closable="false" show-icon />
|
||||
|
||||
<div class="counts"><span v-for="(count, key) in counts" :key="key">{{ actions[key] ?? key }}:{{ count }}</span></div>
|
||||
<el-table v-if="summaries.length" :data="summaries"><el-table-column prop="key" label="平账组" min-width="100" /><el-table-column prop="receipt_total" label="纳入回款" min-width="110" /><el-table-column prop="excluded_receipt_total" label="未纳入回款" min-width="115" /><el-table-column prop="allocated_total" label="分配合计" min-width="110" /><el-table-column prop="opening_remaining" label="剩余旧应收" min-width="110" /><el-table-column prop="prepayment_remaining" label="预收待发货" min-width="110" /><el-table-column prop="performance_total" label="累计可核对计提" min-width="130" /><el-table-column prop="month_performance_total" label="本月可核对计提" min-width="140" /><el-table-column label="待补说明" min-width="180"><template #default="{row}">{{ reasonText(row.reasons) || '逐笔见下表;经理工资尚未生成' }}</template></el-table-column></el-table>
|
||||
<div class="toolbar"><el-select v-model="action" clearable placeholder="全部处理结果" aria-label="筛选处理结果" @change="linePage = 1; loadLines()"><el-option v-for="(label,key) in actions" :key="key" :value="key" :label="label" /></el-select><el-button @click="show(current)">刷新结果</el-button><el-button v-if="current.kind === 'CALC_PREVIEW' && ['SUCCEEDED', 'PARTIAL'].includes(current.status)" :loading="creating" @click="replay" :disabled="!permissionUser.can('tplus:preview')" :title="permissionUser.permissionReason('tplus:preview')">按按原依据重新核对</el-button><el-button :disabled="(!current.snapshot_hash) || !permissionUser.can('tplus:export')" @click="exportReport" :title="permissionUser.permissionReason('tplus:export')">导出核对报告</el-button></div>
|
||||
<el-table :data="lines" empty-text="任务尚未处理或当前筛选没有记录">
|
||||
<div class="toolbar"><el-select v-model="action" clearable placeholder="全部处理结果" aria-label="筛选处理结果" @change="linePage = 1; loadLines()"><el-option v-for="(label,key) in actions" :key="key" :value="key" :label="label" /></el-select><el-button @click="show(current)">刷新结果</el-button><el-button v-if="['MASTER_PREVIEW', 'BUSINESS_PREVIEW'].includes(current.kind) || current.error_code === 'PREVIEW_STALE'" :loading="creating" :disabled="!permissionUser.can('tplus:preview')" @click="regeneratePreview">重新生成预览</el-button><el-button v-if="current.kind === 'CALC_PREVIEW' && ['SUCCEEDED', 'PARTIAL'].includes(current.status)" :loading="creating" @click="replay" :disabled="!permissionUser.can('tplus:preview')" :title="permissionUser.permissionReason('tplus:preview')">按原依据重新核对</el-button><el-button :disabled="(!current.snapshot_hash) || !permissionUser.can('tplus:export')" @click="exportReport" :title="permissionUser.permissionReason('tplus:export')">导出核对报告</el-button></div>
|
||||
<el-alert v-if="linesFailed" title="明细加载失败,请点击刷新结果重试。" type="error" :closable="false" show-icon />
|
||||
<el-table v-if="isMaster" key="master" v-loading="linesLoading" :data="lines" empty-text="任务尚未处理或当前筛选没有记录">
|
||||
<el-table-column label="数据类型" width="90"><template #default="{row}">{{ entityLabels[row.entity_type] ?? row.entity_type }}</template></el-table-column>
|
||||
<el-table-column prop="source_id" label="源记录 ID" width="110" />
|
||||
<el-table-column label="工号 / 源编号" min-width="130"><template #default="{row}">{{ value(row.data, 'source_no') }}</template></el-table-column>
|
||||
<el-table-column label="名称" min-width="160"><template #default="{row}">{{ rowName(row.data) }}</template></el-table-column>
|
||||
<el-table-column label="处理结果" width="100"><template #default="{row}">{{ actions[row.action] ?? row.action }}</template></el-table-column>
|
||||
<el-table-column label="处理原因 / 下一步" min-width="260"><template #default="{row}">{{ lineReason(row) }}</template></el-table-column>
|
||||
<el-table-column v-if="hasProducts" label="规格型号" min-width="140"><template #default="{row}">{{ masterValue(row.data, 'spec_model', 'source_specification') }}</template></el-table-column>
|
||||
<el-table-column v-if="hasProducts" label="单位" width="80"><template #default="{row}">{{ masterValue(row.data, 'unit', 'source_unit') }}</template></el-table-column>
|
||||
</el-table>
|
||||
<el-table v-else key="calculation" v-loading="linesLoading" :data="lines" empty-text="任务尚未处理或当前筛选没有记录">
|
||||
<el-table-column prop="source_id" label="源记录 / 平账组" min-width="130" /><el-table-column label="处理结果" min-width="100"><template #default="{row}">{{ actions[row.action] ?? row.action }}</template></el-table-column>
|
||||
<el-table-column label="名称 / 回款单" min-width="150"><template #default="{row}">{{ rowName(row.data) }}</template></el-table-column>
|
||||
<el-table-column label="发货单 / 明细" min-width="140"><template #default="{row}">{{ value(row.data, 'shipment_no') }} / {{ value(row.data, 'line_id') }}</template></el-table-column>
|
||||
<el-table-column v-for="[key,label] in [['accounting_voucher_code','关联凭证示例'],['amount','匹配金额'],['shipment_remaining_after','分配后该明细剩余应收'],['days','回款天数'],['allocated_cost','指导成本分摊'],['converted_profit','折算利润'],['profit_threshold','利润门槛(%)'],['rate_percent','计提比例'],['performance','计提金额'],['personnel_id','归属工号']]" :key="key" :label="label" min-width="110"><template #default="{row}">{{ value(row.data, key!) }}</template></el-table-column>
|
||||
<el-table-column label="待补或处理原因" min-width="220"><template #default="{row}">{{ reasonText(row.reason) || (row.data.excluded_manager_own ? '经理本人业务,已排除绩效汇总' : row.data.manager_pending ? '经理分管关系待补,不生成经理工资' : '—') }}</template></el-table-column>
|
||||
<el-table-column label="待补或处理原因" min-width="220"><template #default="{row}">{{ lineReason(row) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<AppPagination :total="linesTotal" :page-num="linePage" :page-size="20" @page-change="linePage = $event; loadLines()" @size-change="loadLines" />
|
||||
</template>
|
||||
<template #footer><el-button @click="details=false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="historyOpen" title="补充资料修改历史" width="min(850px, 94vw)"><el-table :data="history"><el-table-column prop="version" label="版本" width="70" /><el-table-column prop="evidence" label="确认依据 / 修改原因" min-width="260" /><el-table-column prop="requested_by" label="操作人员编号" width="110" /><el-table-column label="时间" min-width="160"><template #default="{row}">{{ formatDateTime(row.create_time) }}</template></el-table-column></el-table></el-dialog>
|
||||
<el-dialog v-model="historyOpen" title="补充资料修改历史" width="min(850px, 94vw)"><el-table :data="history"><el-table-column prop="version" label="版本" width="70" /><el-table-column prop="evidence" label="确认依据 / 修改原因" min-width="260" /><el-table-column prop="requested_by" label="操作人员编号" width="110" /><el-table-column label="时间" min-width="160"><template #default="{row}">{{ formatDateTime(row.create_time) }}</template></el-table-column></el-table><template #footer><el-button @click="historyOpen=false">关闭</el-button></template></el-dialog>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workbench { margin-top: 24px; } .section-head, .toolbar { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; }
|
||||
h2 { margin: 0 0 8px; font-size: 18px; } h3 { margin: 24px 0 12px; font-size: 16px; } .create-form { max-width: 720px; padding: 16px 0; }
|
||||
h2 { margin: 0 0 8px; font-size: 16px; } h3 { margin: 24px 0 12px; font-size: 15px; } .create-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 24px; padding: 20px 0; }
|
||||
.create-form :deep(.el-form-item__label) { height: auto; line-height: 1.6; }
|
||||
.create-form .form-hint { width: 100%; }
|
||||
.create-form > .el-button { grid-column: 1 / -1; justify-self: start; }
|
||||
.toolbar { justify-content: flex-start; margin-top: 16px; flex-wrap: wrap; } .toolbar .el-select { width: 220px; } .counts { display: flex; gap: 24px; margin: 16px 0; flex-wrap: wrap; }
|
||||
.workbench :deep(.el-table .el-button + .el-button) { margin-left: 12px; }
|
||||
@media(max-width: 800px) { .create-form { grid-template-columns: minmax(0, 1fr); } }
|
||||
@media(max-width: 640px) { .section-head { align-items: flex-start; flex-direction: column; } .toolbar .el-select { width: 100%; } }
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Refresh, Download, ArrowRight, Connection } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import * as api from '@/api/tplus'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
@@ -13,6 +14,7 @@ import BatchDetailDialog from './BatchDetailDialog.vue'
|
||||
import CreateTaskDialog from './CreateTaskDialog.vue'
|
||||
import SyncProgress from './SyncProgress.vue'
|
||||
import WorkbenchPanel from './WorkbenchPanel.vue'
|
||||
import BusinessSyncPanel from './BusinessSyncPanel.vue'
|
||||
import { scopeLabel, statuses } from './labels'
|
||||
import { useSyncPage } from './useSyncPage'
|
||||
|
||||
@@ -61,19 +63,19 @@ async function retry(row: api.TPlusBatch): Promise<void> {
|
||||
<template>
|
||||
<div class="tplus-console">
|
||||
<header class="console-header">
|
||||
<div><h1>T+ 数据同步</h1><p>查看同步进度、管理自动采集,并核对每次读取结果。</p></div>
|
||||
<div class="console-actions"><el-button :loading="loading" @click="refresh()">刷新状态</el-button><el-button type="primary" :disabled="!canCreate" :title="user.permissionReason('tplus:collect')" @click="creating = true">立即同步</el-button></div>
|
||||
<div><h1 class="page-title">T+ 数据同步</h1><p>查看任务进度、管理每周采集,核对读取结果后再导入业务。</p><span class="waterline"></span></div>
|
||||
<div class="console-actions"><el-button :icon="Refresh" :loading="loading" @click="refresh()">刷新状态</el-button><el-button type="primary" :icon="Download" :disabled="!canCreate" :title="user.permissionReason('tplus:collect')" @click="creating = true">立即同步</el-button></div>
|
||||
</header>
|
||||
<div class="connection-line"><span :class="{'is-connected': overview?.is_configured}">{{ connectionLabel }}</span><span>仅读取 T+ 数据</span><span v-if="refreshedAt">更新于 {{ refreshedAt.toLocaleTimeString('zh-CN', {hour12: false}) }}</span></div>
|
||||
<div class="connection-line"><span class="connection-state" :class="{'is-connected': overview?.is_configured}"><el-icon aria-hidden="true"><Connection /></el-icon>{{ connectionLabel }}</span><span>仅读取 T+ 数据</span><span v-if="refreshedAt" class="connection-updated">更新于 {{ refreshedAt.toLocaleTimeString('zh-CN', {hour12: false}) }}</span></div>
|
||||
<el-alert v-if="failed" class="connection-alert" title="状态暂时更新失败,已保留上次结果" description="请检查网络后刷新;当前显示的进度可能不是最新状态。" type="warning" :closable="false" show-icon />
|
||||
<el-alert v-else-if="overview && !overview.is_configured" class="connection-alert" title="暂时无法读取新数据" description="已有同步记录和资料仍可查看,请联系维护人员检查读取配置。" type="warning" :closable="false" show-icon />
|
||||
<div v-if="submittedNumber" class="submitted-task" role="status"><span>已提交任务 <span class="cell-code">{{ submittedNumber }}</span>,处理结果会自动更新。</span><el-button link type="primary" @click="detailNumber = submittedNumber">查看已提交任务</el-button></div>
|
||||
|
||||
<!-- 网格为可伸缩主区 + 320px设置区 + 16px间距,窄屏合并单列,沿用现有全局导航。 -->
|
||||
<div class="sync-top-grid">
|
||||
<div class="console-panel progress-panel"><SyncProgress :batch="overview?.current_batch ?? null" @detail="detailNumber = $event" /></div>
|
||||
<div class="console-panel progress-panel"><SyncProgress :batch="overview?.current_batch ?? null" :stale="failed || !!automation?.alerts.some(alert => alert.code === 'WORKER_OFFLINE')" @detail="detailNumber = $event" /></div>
|
||||
<section class="console-panel automation-panel" aria-labelledby="automation-title">
|
||||
<div class="automation-title"><div><p class="section-caption">同步计划</p><h2 id="automation-title">每周自动采集</h2></div><el-switch :model-value="!!automation?.enabled" :loading="saving" :disabled="!canConfigure" :title="switchReason" aria-label="自动采集开关" @change="changeAutomation" /></div>
|
||||
<div class="automation-title"><h2 id="automation-title">每周自动采集</h2><el-switch :model-value="!!automation?.enabled" :loading="saving" :disabled="!canConfigure" :title="switchReason" aria-label="自动采集开关" @change="changeAutomation" /></div>
|
||||
<p class="automation-state" role="status">{{ saving ? '正在保存设置…' : automation?.enabled ? '自动采集已开启' : '自动采集已关闭' }}</p>
|
||||
<dl class="schedule-details"><div><dt>采集时间</dt><dd>每周{{ weekdays[automation?.weekday ?? 0] }} {{ automation?.time ?? '02:00' }}<small>北京时间</small></dd></div><div><dt>读取服务</dt><dd>{{ phases[automation?.worker_phase ?? 'UNKNOWN'] }}</dd></div><div><dt>最近成功</dt><dd>{{ formatDateTime(automation?.last_success_at) }}</dd></div><div v-if="automation?.next_retry_at"><dt>下次重试</dt><dd>{{ formatDateTime(automation.next_retry_at) }}</dd></div></dl>
|
||||
<p class="settings-help">开启后按周读取;关闭只停止后续自动排队,当前任务继续完成。手动同步仍可使用。</p>
|
||||
@@ -82,12 +84,14 @@ async function retry(row: api.TPlusBatch): Promise<void> {
|
||||
</div>
|
||||
<el-alert v-for="alert in automation?.alerts ?? []" :key="alert.code" class="connection-alert" :title="alerts[alert.code] ?? '采集状态需要检查,请查看相关任务。'" type="warning" :closable="false" show-icon><el-button v-if="alert.batch_no" link type="primary" @click="detailNumber = alert.batch_no">查看相关任务</el-button></el-alert>
|
||||
|
||||
<div class="sync-boundary"><b>采集完成后,还需核对并导入业务</b><span>此处保存从 T+ 读取的数据;资料导入、绩效核算和工资生成分别执行。</span><el-button link @click="activeTab = 'workspace'">前往资料与导入</el-button></div>
|
||||
<BusinessSyncPanel />
|
||||
<div class="sync-boundary"><div><b>核算资料与历史导入</b><p>绩效核算和工资生成分别执行。</p></div><el-button link type="primary" :icon="ArrowRight" @click="activeTab = 'workspace'">前往资料与导入</el-button></div>
|
||||
<section class="console-panel records-panel">
|
||||
<el-tabs v-model="activeTab" class="console-tabs">
|
||||
<el-tab-pane label="同步记录" name="records">
|
||||
<div class="records-heading"><div><h2>同步记录</h2><p>按任务查看读取范围、实际进度和异常结果。</p></div></div>
|
||||
<div class="records-toolbar"><div class="record-filters"><el-select v-model="status" clearable placeholder="全部状态" aria-label="批次状态" @change="search"><el-option v-for="(label, key) in statuses" :key="key" :label="label" :value="key" /></el-select><el-select v-model="taskType" clearable placeholder="全部任务" aria-label="任务类型" @change="search"><el-option label="数据读取" value="READ" /><el-option label="关联检查" value="AUDIT" /></el-select></div><span class="record-summary">共 {{ total }} 次任务<span v-if="overview"> · {{ (overview.status_counts.RUNNING ?? 0) + (overview.status_counts.QUEUED ?? 0) }} 次进行中</span></span></div>
|
||||
<el-table :data="records" class="sync-table" v-loading="loading && !records.length" empty-text="还没有同步记录,点击“立即同步”开始读取" aria-label="同步任务记录">
|
||||
<el-table :data="records" class="sync-table" v-loading="loading && !records.length" empty-text="暂无符合条件的同步记录" aria-label="同步任务记录">
|
||||
<el-table-column label="同步任务" min-width="240"><template #default="{row}"><button class="batch-link" type="button" @click="detailNumber = row.batch_no">{{ row.batch_no }}</button><p class="row-note">{{ formatDateTime(row.create_time) }}<span class="trigger-label">{{ row.trigger_type === 'SCHEDULED' ? '每周自动采集' : row.trigger_type === 'AUTO_RETRY' ? `自动重试 ${row.automatic_attempt}/2` : '手动任务' }}</span></p></template></el-table-column>
|
||||
<el-table-column label="范围" min-width="165"><template #default="{row}"><span class="scope-copy">{{ scopeLabel(row) }}</span></template></el-table-column>
|
||||
<el-table-column label="进度 / 状态" min-width="150"><template #default="{row}"><el-tag :type="statusType(row.status)">{{ statuses[row.status as api.TaskStatus] }}</el-tag><p class="row-note">{{ row.progress?.percentage == null ? row.status === 'QUEUED' ? '等待开始' : `${row.source_record_count.toLocaleString()} 条已处理` : `${row.progress.percentage}% · ${row.source_record_count.toLocaleString()} 条` }}</p></template></el-table-column>
|
||||
@@ -107,33 +111,37 @@ async function retry(row: api.TPlusBatch): Promise<void> {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tplus-console { --sync-gap: 16px; color: var(--ys-ink); }
|
||||
.tplus-console { --sync-gap: 20px; container-type: inline-size; min-width: 0; color: var(--ys-ink); }
|
||||
.console-header { display: flex; align-items: center; justify-content: space-between; gap: var(--sync-gap); margin-bottom: 14px; }
|
||||
.console-header h1 { font-size: 24px; line-height: 1.4; font-weight: 600; margin: 0; }
|
||||
.console-header p { font-size: 14px; line-height: 1.7; color: var(--ys-ink-2); margin: 6px 0 0; }
|
||||
.console-header h1 { line-height: 1.4; }
|
||||
.console-header p { font-size: 13px; line-height: 1.7; color: var(--ys-ink-2); margin: 8px 0 0; }
|
||||
.console-actions { display: flex; gap: 8px; flex-shrink: 0; }
|
||||
.console-actions .el-button + .el-button { margin: 0; }
|
||||
.connection-line { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 18px; margin-bottom: 20px; font-size: 13px; color: var(--ys-ink-2); }
|
||||
.connection-line .is-connected { color: var(--el-color-success-dark-2); }
|
||||
.connection-state { display: inline-flex; gap: 6px; align-items: center; }
|
||||
.connection-updated { margin-left: auto; font-variant-numeric: tabular-nums; }
|
||||
.sync-top-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: var(--sync-gap); align-items: start; }
|
||||
.console-panel { background: var(--ys-paper); border: 1px solid var(--ys-line); border-radius: 10px; box-shadow: none; min-width: 0; }
|
||||
.console-panel { background: var(--ys-paper); border: 1px solid var(--ys-line); border-radius: var(--ys-radius); box-shadow: var(--ys-shadow-card); min-width: 0; }
|
||||
.progress-panel, .automation-panel { display: flex; flex-direction: column; padding: 22px 24px; }
|
||||
.automation-title { display: flex; align-items: center; justify-content: space-between; gap: var(--sync-gap); }
|
||||
.automation-title h2, .audit-content h2 { margin: 0; font-size: 16px; font-weight: 600; }
|
||||
.section-caption { font-size: 13px; color: var(--ys-ink-2); margin: 0 0 6px; }
|
||||
.automation-state { margin: 12px 0 18px; font-size: 13px; color: var(--ys-ink-2); }
|
||||
.automation-state { margin: 12px 0 18px; padding-bottom: 16px; border-bottom: 1px solid var(--ys-line-soft); font-size: 13px; color: var(--ys-ink-2); }
|
||||
.automation-panel :deep(.el-switch) { --el-switch-on-color: var(--ys-ok); }
|
||||
.schedule-details { margin: 0; flex: 1; align-content: start; }
|
||||
.schedule-details > div { display: grid; grid-template-columns: 70px minmax(0,1fr); align-items: baseline; gap: 8px; margin: 12px 0; font-size: 13px; }
|
||||
.schedule-details dt { color: var(--ys-ink-2); }
|
||||
.schedule-details dd { margin: 0; line-height: 1.65; font-variant-numeric: tabular-nums; }
|
||||
.schedule-details small { margin-left: 6px; color: var(--ys-ink-2); font-size: 13px; }
|
||||
.settings-help { font-size: 13px; line-height: 1.8; color: var(--ys-ink-2); margin: 18px 0 0; }
|
||||
.sync-boundary { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px 14px; padding: 18px 2px; font-size: 13px; line-height: 1.7; }
|
||||
.sync-boundary b { font-weight: 500; }
|
||||
.sync-boundary span { color: var(--ys-ink-2); }
|
||||
.settings-help { font-size: 12px; line-height: 1.8; color: var(--ys-ink-2); margin: 18px 0 0; }
|
||||
.sync-boundary { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px 20px; padding: 18px 0; font-size: 13px; line-height: 1.7; }
|
||||
.sync-boundary b { font-weight: 600; }
|
||||
.sync-boundary p { color: var(--ys-ink-2); margin: 4px 0 0; font-size: 12px; }
|
||||
.sync-boundary .el-button { font-size: 13px; height: auto; }
|
||||
.records-panel { padding: 0 20px 16px; }
|
||||
.records-heading { margin: 6px 0 20px; }
|
||||
.records-heading h2 { margin: 0; font-size: 16px; font-weight: 600; }
|
||||
.records-heading p { margin: 6px 0 0; color: var(--ys-ink-2); font-size: 13px; line-height: 1.7; }
|
||||
.console-tabs :deep(.el-tabs__header) { margin-bottom: 16px; }
|
||||
.console-tabs :deep(.el-tabs__item) { height: 48px; }
|
||||
.console-tabs :deep(.el-tabs__nav-wrap::after) { height: 1px; background: var(--ys-line); }
|
||||
@@ -141,7 +149,7 @@ async function retry(row: api.TPlusBatch): Promise<void> {
|
||||
.record-filters { display: flex; gap: 10px; }
|
||||
.record-filters .el-select { width: 150px; }
|
||||
.record-summary { font-size: 13px; color: var(--ys-ink-2); font-variant-numeric: tabular-nums; }
|
||||
.sync-table :deep(th.el-table__cell) { background: var(--ys-paper); font-size: 13px; font-weight: 500; }
|
||||
.sync-table :deep(th.el-table__cell) { background: var(--ys-mist); font-size: 12px; font-weight: 500; }
|
||||
.sync-table :deep(td.el-table__cell) { padding: 14px 0; }
|
||||
.batch-link { padding: 4px 0; border: 0; background: none; font: 13px var(--ys-font-brand); font-variant-numeric: tabular-nums; color: var(--ys-indigo); cursor: pointer; text-align: left; overflow-wrap: anywhere; }
|
||||
.batch-link:focus-visible { outline: 2px solid var(--ys-indigo); outline-offset: 2px; }
|
||||
@@ -152,7 +160,7 @@ async function retry(row: api.TPlusBatch): Promise<void> {
|
||||
.error-count { color: var(--el-color-danger-dark-2); font-weight: 500; }
|
||||
.records-footer { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; padding-top: 16px; }
|
||||
.records-footer > span { font-size: 13px; color: var(--ys-ink-2); }
|
||||
.records-footer :deep(.app-pagination) { margin: 0; }
|
||||
.records-footer :deep(.app-pager) { margin: 0; }
|
||||
.connection-alert { margin: 12px 0; }
|
||||
.submitted-task { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 16px; margin: 0 0 16px; font-size: 13px; line-height: 1.8; color: var(--ys-ink-2); }
|
||||
.submitted-task > span { min-width: 0; overflow-wrap: anywhere; }
|
||||
@@ -160,6 +168,6 @@ async function retry(row: api.TPlusBatch): Promise<void> {
|
||||
.audit-content h2 { margin-bottom: 14px; }
|
||||
.console-tabs :deep(.workbench) { border: 0; box-shadow: none; padding: 8px 0; margin: 0; }
|
||||
@media (max-width: 1080px) { .sync-top-grid { grid-template-columns: minmax(0,1fr) 290px; } .progress-panel, .automation-panel { padding: 20px; } }
|
||||
@media (max-width: 820px) { .sync-top-grid { grid-template-columns: minmax(0,1fr); } .schedule-details { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 0 20px; } .settings-help { margin-top: 10px; } }
|
||||
@media (max-width: 540px) { .console-header { align-items: flex-start; flex-direction: column; } .console-header h1 { font-size: 22px; } .console-actions { width: 100%; } .console-actions .el-button { flex: 1; } .record-filters { width: 100%; } .record-filters .el-select { width: 50%; } .records-panel { padding: 0 12px 12px; } .progress-panel, .automation-panel { padding: 18px 16px; } .schedule-details { grid-template-columns: minmax(0,1fr); } .records-footer { overflow-x: auto; } }
|
||||
@container (max-width: 960px) { .sync-top-grid { grid-template-columns: minmax(0,1fr); } .schedule-details { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 0 20px; } .settings-help { margin-top: 10px; } }
|
||||
@media (max-width: 540px) { .console-header { align-items: flex-start; flex-direction: column; } .console-actions { width: 100%; } .console-actions .el-button { flex: 1; } .connection-updated { margin-left: 0; } .record-filters { width: 100%; } .record-filters .el-select { width: 50%; } .records-panel { padding: 0 12px 12px; } .progress-panel, .automation-panel { padding: 18px 16px; } .schedule-details { grid-template-columns: minmax(0,1fr); } .records-footer { overflow-x: auto; } }
|
||||
</style>
|
||||
@@ -1,12 +1,26 @@
|
||||
export const factKinds = { PERSONNEL: '人员名单', PRODUCT: '产品分类与单位', CUSTOMER: '客户分类与负责人', GROUP: '平账范围与期初应收', GUIDANCE: '月度指导成本', RECEIPT: '收款凭证核实', SHIPMENT: '发货归属与运费核实', HANDOVER: '业务交接', MANAGER: '经理分管业务', CORRECTION: 'T+更正单处理' }
|
||||
export const workKinds = { MASTER_PREVIEW: '基础导入预览', MASTER_APPLY: '基础数据导入', CALC_PREVIEW: '核算预演', BUSINESS_PREVIEW: '正式业务导入预览', BUSINESS_APPLY: '正式业务导入' }
|
||||
export const workKinds = { MASTER_PREVIEW: '基础导入预览', MASTER_APPLY: '基础数据导入', CALC_PREVIEW: '核算预演', BUSINESS_PREVIEW: '正式业务导入预览', BUSINESS_APPLY: '正式业务导入', BUSINESS_SYNC: '业务自动接入' }
|
||||
export const actions: Record<string, string> = { EXCLUDED: '不纳入本次预演', CREATE: '新增', UPDATE: '更新', SKIP: '跳过', PENDING: '待补充', CONFLICT: '冲突', READY: '可核对', LEGACY: '旧应收冲平', WAITING_SHIPMENT: '预收待发货', QUEUED: '排队中', RUNNING: '处理中', SUCCEEDED: '处理完成', PARTIAL: '部分待处理', FAILED: '失败' }
|
||||
export const reasons: Record<string, string> = {
|
||||
CORRECTION_PAIR_REQUIRED: '请先完成正负更正单成对处理',
|
||||
CORRECTION_RECONFIRM_REQUIRED: '更正单或回款来源已变化,请重新核对更正对',
|
||||
TASK_AUTHORIZATION_REVOKED: '请求账号已停用或权限已撤销,请重新授权后创建任务',
|
||||
BUSINESS_SYNC_SCHEDULE_FAILED: '业务自动接入排队失败,采集结果已保留,请检查后重试',
|
||||
SOURCE_VALUE_INVALID: '来源日期或数值无效,或超出系统支持的精度范围,请核对原单',
|
||||
SOURCE_DOCUMENT_INACTIVE: '来源单据尚未生效,请核对原单状态',
|
||||
BUSINESS_TERMINAL_REVIEW_REQUIRED: '业务已完成或终止,新增来源单据需要人工核对',
|
||||
LEGACY_BUSINESS_REVIEW_REQUIRED: '已有历史导入业务,请先核对来源关联',
|
||||
PAYROLL_MONTH_LOCKED: '单据所属月份已结算或生成工资,请核对后处理',
|
||||
CUSTOMER_SCOPE_CONFLICT: '同一客户的期初或历史范围不一致,请先合并核对资料',
|
||||
GUIDANCE_FINALIZATION_REQUIRED: '发货月份指导成本尚未录入完毕并封存',
|
||||
CUSTOMER_SCOPE_ALREADY_SELECTED: '该客户已纳入本次平账,重复选择已跳过',
|
||||
FORMAL_SOURCE_CHANGED: '源单据或依据与正式版本不同,请先核对差异',
|
||||
SOURCE_PRICE_REVIEW_REQUIRED: '已接入单价与来源未税单价不一致,请核对原单及正式明细',
|
||||
PENDING_SOURCE_CUSTOMER_CHANGED: '待核对单据的来源客户已变化,原关联已保留,请核对原单',
|
||||
PENDING_SOURCE_OUT_OF_RANGE: '待核对单据的来源日期已早于接入起点,原关联已保留,请核对原单',
|
||||
LEGACY_BUSINESS_APPLY_DISABLED: '旧规则预览不能执行,请使用自动业务接入重新生成计划',
|
||||
ORDER_SHIPMENT_MISMATCH: '发货关联的订单明细、订单号或产品不一致,请核对来源',
|
||||
SOURCE_DOCUMENT_MISSING: '来源单据在最新完整快照中消失,已有记录已保留,请核对',
|
||||
FORMAL_TARGET_CHANGED: '正式单据与已导入资料不一致,请先核对差异',
|
||||
SOURCE_ALREADY_IMPORTED: '该源单据已经导入,不能重复占用',
|
||||
CUSTOMER_IMPORT_REQUIRED: '请先完成客户基础导入',
|
||||
@@ -39,6 +53,7 @@ export const reasons: Record<string, string> = {
|
||||
GROUP_FACT_REQUIRED: '尚未登记该平账组',
|
||||
PREPAYMENT_PARAMETERS_PENDING: '请核对预收款到账日期及适用的计提参数',
|
||||
HANDOVER_PENDING: '离职日期或接手依据待补,不能猜测归属',
|
||||
PERSONNEL_ROLE_PENDING: '该业务发生月份的历史岗位待确认',
|
||||
ACCOUNTING_VOUCHER_INVALID: '关联凭证缺失、已作废或制单状态异常',
|
||||
VOUCHER_EVIDENCE_CONFLICT: '凭证关联与人工核实不一致,请复核',
|
||||
NOT_GENERATED_VOUCHER: '无关联总账凭证,不纳入本次预演',
|
||||
|
||||
Reference in new issue
Block a user