9 Commits

15 changed files with 263 additions and 47 deletions

2
.gitignore vendored
View File

@ -11,6 +11,8 @@ inventory-web/dist/
inventory-web/*.local inventory-web/*.local
# --- 通用忽略 --- # --- 通用忽略 ---
# 根目录 .envdocker compose 读取,含 Track Webhook 密钥等敏感配置)
.env
.idea/ .idea/
.vscode/ .vscode/
.DS_Store .DS_Store

View File

@ -24,6 +24,8 @@ ssh $SERVER "mkdir -p $REMOTE_BACKUP_DIR && \
--exclude='inventory-backend/uploads' \ --exclude='inventory-backend/uploads' \
--exclude='inventory-backend/pgdata' \ --exclude='inventory-backend/pgdata' \
--exclude='inventory-backend/pgdata_docker' \ --exclude='inventory-backend/pgdata_docker' \
--exclude='inventory-backend/venv' \
--exclude='inventory-web/node_modules' \
inventory-backend inventory-web docker-compose.prod.yml && \ inventory-backend inventory-web docker-compose.prod.yml && \
echo '>> 备份 SSL 证书...' && \ echo '>> 备份 SSL 证书...' && \
(tar -czf $REMOTE_BACKUP_DIR/ssl_backup.tar.gz ssl 2>/dev/null || echo '⚠️ ssl 目录不可读(属主 root)或不存在,跳过该备份') && \ (tar -czf $REMOTE_BACKUP_DIR/ssl_backup.tar.gz ssl 2>/dev/null || echo '⚠️ ssl 目录不可读(属主 root)或不存在,跳过该备份') && \

View File

@ -38,11 +38,12 @@ services:
MAIL_USE_SSL: "true" MAIL_USE_SSL: "true"
MAIL_USE_TLS: "false" MAIL_USE_TLS: "false"
# Track 系统联动(扫码入库/出库/Webhook 通知) # Track 系统联动(扫码入库/出库/Webhook 通知)
# ★ 生产容器名 track_backend_prod 访问MOM 与 Track 同处 mom_net 外部网络) # ★ 生产地址硬编码为容器名 track_backend_prod(同一服务器 mom_net 网络内互通),
TRACK_WEBHOOK_URL: ${TRACK_WEBHOOK_URL:-http://track_backend_prod:8000/api/v1/external/webhooks/mom-inbound} # 避免被服务器上残留的 .env 覆盖成 dev 的 track_backend
TRACK_WEBHOOK_URL: http://track_backend_prod:8000/api/v1/external/webhooks/mom-inbound
TRACK_WEBHOOK_KEY: ${TRACK_WEBHOOK_KEY:-2ce5fedb48fde3fd7e0abf67472a5027b03e9ae6f19cf768} TRACK_WEBHOOK_KEY: ${TRACK_WEBHOOK_KEY:-2ce5fedb48fde3fd7e0abf67472a5027b03e9ae6f19cf768}
TRACK_API_URL: ${TRACK_API_URL:-http://track_backend_prod:8000} TRACK_API_URL: http://track_backend_prod:8000
TRACK_OUTBOUND_WEBHOOK_URL: ${TRACK_OUTBOUND_WEBHOOK_URL:-http://track_backend_prod:8000/api/v1/external/webhooks/mom-outbound} TRACK_OUTBOUND_WEBHOOK_URL: http://track_backend_prod:8000/api/v1/external/webhooks/mom-outbound
depends_on: depends_on:
- db - db
# 加入 mom_net 外部网络,与 Track 生产容器互通 # 加入 mom_net 外部网络,与 Track 生产容器互通

View File

@ -10,3 +10,23 @@ uploads
pgdata pgdata
.env .env
simhei.ttf simhei.ttf
# ---- 补充排除项 ----
# Windows 残留空文件
nul
# 数据库备份文件(避免打进镜像/context
*.dump
*.sql.gz
*.tar.gz
*.gz
*.log
# 数据库迁移脚本(运行时用 volume 挂载宿主机,镜像内不需要)
db_migrations
# 测试脚本与缓存
test_*.py
tests
.pytest_cache
.coverage
# 运维修复脚本(宿主机保留,无需进镜像)
fix_*.py
fix_*.sql

View File

@ -5,9 +5,12 @@ WORKDIR /app
# 1. 复制依赖并安装 # 1. 复制依赖并安装
COPY requirements.txt . COPY requirements.txt .
# 安装依赖 + gunicorn # 安装依赖 + gunicorn(清华镜像源 + BuildKit 缓存挂载)
RUN pip install --no-cache-dir -r requirements.txt && \ # ★ 关键优化pip 下载的 wheel 会缓存在宿主 /root/.cache/pip
pip install --no-cache-dir gunicorn # 只要 requirements.txt 不变重复构建不再重新下载首次约100s之后秒级
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple -r requirements.txt && \
pip install -i https://pypi.tuna.tsinghua.edu.cn/simple gunicorn
# 2. 复制后端代码 # 2. 复制后端代码
COPY . . COPY . .

View File

@ -198,9 +198,10 @@ def save_bom():
@jwt_required() @jwt_required()
@permission_required('bom_manage') @permission_required('bom_manage')
def get_bom_with_stock_by_no(bom_no): def get_bom_with_stock_by_no(bom_no):
"""根据 BOM 编号获取配方详情及库存信息""" """根据 BOM 编号 (和可选 version) 获取配方详情及库存信息"""
try: try:
data = BomService.get_bom_with_stock_by_bom_no(bom_no) version = request.args.get('version')
data = BomService.get_bom_with_stock_by_bom_no(bom_no, version=version)
if not data: if not data:
return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404 return jsonify({'code': 404, 'msg': 'BOM 不存在'}), 404
# 字段级脱敏 # 字段级脱敏

View File

@ -422,11 +422,11 @@ class BomService:
return bom_no return bom_no
@staticmethod @staticmethod
def get_bom_with_stock_by_bom_no(bom_no): def get_bom_with_stock_by_bom_no(bom_no, version=None):
""" """
根据 bom_no 获取配方详情,并计算(已修复 N+1 性能问题) 根据 bom_no (和可选 version) 获取配方详情,并计算(已修复 N+1 性能问题)
""" """
detail = BomService.get_bom_detail(bom_no) detail = BomService.get_bom_detail(bom_no, version=version)
if not detail or not detail.get('children'): if not detail or not detail.get('children'):
return detail return detail

View File

@ -33,4 +33,6 @@ pytz
# [新增] 进度条库 (脚本和任务所需) # [新增] 进度条库 (脚本和任务所需)
tqdm>=4.66.0 tqdm>=4.66.0
# [新增] pgvector 向量数据库支持(以图搜图 / 实时向量提取) # [新增] pgvector 向量数据库支持(以图搜图 / 实时向量提取)
pgvector>=0.2.0 pgvector>=0.2.0
# [新增] HTTP 客户端Track 系统 Webhook 异步通知)
httpx>=0.24.0

View File

@ -239,7 +239,7 @@ const handleLogout = () => {
<footer v-if="!isLoginPage" class="app-footer"> <footer v-if="!isLoginPage" class="app-footer">
<span class="version-tag"> <span class="version-tag">
<el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon> <el-icon style="vertical-align: middle; margin-right: 4px"><InfoFilled /></el-icon>
当前版本:V3.69 当前版本:V3.70
</span> </span>
</footer> </footer>

View File

@ -19,12 +19,13 @@ export function getBomSummary(params?: { keyword?: string }) {
} }
// 获取BOM详情含库存信息 // 获取BOM详情含库存信息
export function getBomWithStock(bomNo: string) { export function getBomWithStock(bomNo: string, version?: string) {
const trimmed = bomNo.replace(/^\/+|\/+$/g, ''); const trimmed = bomNo.replace(/^\/+|\/+$/g, '');
const encoded = encodeURIComponent(trimmed); const encoded = encodeURIComponent(trimmed);
return request({ return request({
url: `/v1/bom/stock/${encoded}`, url: `/v1/bom/stock/${encoded}`,
method: 'get' method: 'get',
params: version ? { version } : {}
}) })
} }

View File

@ -247,6 +247,7 @@
</div> </div>
<template #footer> <template #footer>
<el-button @click="bomSelectVisible = false">取消</el-button> <el-button @click="bomSelectVisible = false">取消</el-button>
<el-button :icon="Printer" :disabled="bomDetailList.length === 0" @click="printBomChecklist" title="先选择 BOM 配方后即可打印清单">打印 BOM 清单</el-button>
<el-button <el-button
v-if="userStore.hasPermission('op_borrow_apply:operation')" v-if="userStore.hasPermission('op_borrow_apply:operation')"
type="primary" type="primary"
@ -257,6 +258,62 @@
</template> </template>
</el-dialog> </el-dialog>
<!-- BOM 打印区域隐藏打印时克隆到 iframe -->
<div id="bom-print-area" class="print-bom-area" style="display:none;">
<h2 style="text-align:center; margin:0 0 8px;">BOM 借库清单</h2>
<p style="text-align:center; margin:0 0 16px; font-size:13px;">
BOM: <b>{{ selectedBomLabel }}</b> 套数: <b>{{ bomSets }}</b> 生成时间: {{ bomPrintTime }}
</p>
<table class="print-table">
<thead>
<tr>
<th>序号</th><th>物料名称</th><th>规格型号</th><th>需求量</th><th>可用库存</th><th>缺料</th><th>结果</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in bomDetailList" :key="idx">
<td>{{ idx + 1 }}</td>
<td>{{ row.name }}</td>
<td>{{ row.sku }}</td>
<td>{{ row.need }}</td>
<td>{{ row.available }}</td>
<td>{{ row.shortage > 0 ? row.shortage : '-' }}</td>
<td>{{ row.shortage > 0 ? '缺货未加' : '可加' }}</td>
</tr>
</tbody>
</table>
<p style="margin-top:12px;">
可加 <b style="color:#67C23A;">{{ bomAvailableCount }}</b>
缺货 <b style="color:#F56C6C;">{{ bomShortageCount }}</b>
</p>
<p style="color:#909399; font-size:12px;">缺货物料不会加入本次借库申请单请补货后重新按 BOM 添加</p>
<!-- 欠料待补签收区仅当有缺货时打印此联与出库选单一致 -->
<div v-if="bomShortageCount > 0" style="margin-top: 20px; border-top: 2px dashed #000; padding-top: 10px;">
<p style="text-align:center; margin:0 0 8px; font-size:13px;">
BOM: <b>{{ selectedBomLabel }}</b> 套数: <b>{{ bomSets }}</b> 生成时间: {{ bomPrintTime }}
</p>
<p style="font-weight: bold; font-size: 14px;"> 欠料待补清单请补料后在此签字</p>
<table class="print-table" style="margin-top: 6px;">
<thead>
<tr>
<th>序号</th><th>物料名称</th><th>规格型号</th><th>需补数量</th><th>补领人签字</th><th>日期</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in bomDetailList.filter(i => i.shortage > 0)" :key="idx">
<td>{{ idx + 1 }}</td>
<td>{{ row.name }}</td>
<td>{{ row.sku }}</td>
<td>{{ row.shortage }}</td>
<td></td>
<td></td>
</tr>
</tbody>
</table>
</div>
</div>
<el-dialog <el-dialog
v-model="previewVisible" v-model="previewVisible"
title="借库单核对与打印" title="借库单核对与打印"
@ -347,6 +404,19 @@
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="预计归还日期">
<el-date-picker
v-model="requestExpectedReturn"
type="date"
placeholder="请选择日期"
style="width: 100%"
value-format="YYYY-MM-DD"
:disabled="requestIndefinite"
/>
<el-checkbox v-model="requestIndefinite" @change="handleRequestIndefiniteChange" style="margin-left: 12px;">
长期借用
</el-checkbox>
</el-form-item>
<el-form-item label="申请原因"> <el-form-item label="申请原因">
<el-input <el-input
v-model="requestRemark" v-model="requestRemark"
@ -473,6 +543,8 @@ const printLoading = ref(false)
const requestDialogVisible = ref(false) const requestDialogVisible = ref(false)
const borrowerName = ref('') const borrowerName = ref('')
const requestRemark = ref('') const requestRemark = ref('')
const requestExpectedReturn = ref('')
const requestIndefinite = ref(false)
const requestApproverId = ref<number | null>(null) const requestApproverId = ref<number | null>(null)
const approvers = ref<any[]>([]) const approvers = ref<any[]>([])
const requestSubmitting = ref(false) const requestSubmitting = ref(false)
@ -495,6 +567,7 @@ const bomOptions = ref<any[]>([])
const selectedBomNo = ref('') const selectedBomNo = ref('')
const bomSets = ref(1) const bomSets = ref(1)
const currentBomDetail = ref<any[]>([]) // 当前选中的BOM明细 const currentBomDetail = ref<any[]>([]) // 当前选中的BOM明细
const bomPrintTime = ref('')
// BOM 树形数据(将分组数据映射为 el-tree-select 需要的结构) // BOM 树形数据(将分组数据映射为 el-tree-select 需要的结构)
const treeData = computed(() => { const treeData = computed(() => {
@ -503,7 +576,8 @@ const treeData = computed(() => {
label: `${group.category} (${group.count})`, label: `${group.category} (${group.count})`,
disabled: true, // 禁止选中分类本身 disabled: true, // 禁止选中分类本身
children: (group.items || []).map((b: any) => ({ children: (group.items || []).map((b: any) => ({
value: b.bom_no, // ★ 用 bom_no + version 组合作为唯一 value避免同一产品多版本时回显错乱
value: `${b.bom_no}###${b.version}`,
label: `${b.bom_no} - ${b.parent_name} - ${b.version}` label: `${b.bom_no} - ${b.parent_name} - ${b.version}`
})) }))
})) }))
@ -546,6 +620,14 @@ const bomDetailList = computed(() => {
}) })
const hasShortage = computed(() => bomDetailList.value.some((item: any) => item.shortage > 0) && bomSets.value > maxBuildableSets.value) const hasShortage = computed(() => bomDetailList.value.some((item: any) => item.shortage > 0) && bomSets.value > maxBuildableSets.value)
const bomAvailableCount = computed(() => bomDetailList.value.filter(i => i.shortage === 0).length)
const bomShortageCount = computed(() => bomDetailList.value.filter(i => i.shortage > 0).length)
// BOM 选择显示标签bom_no###version → "bom_no (version)"
const selectedBomLabel = computed(() => {
const [no, ver] = (selectedBomNo.value || '').split('###')
return ver ? `${no} (${ver})` : no
})
// 打印相关 // 打印相关
const currentTime = ref('') const currentTime = ref('')
@ -715,13 +797,15 @@ const openBomSelect = async () => {
} }
// 监听 BOM 选择变化,自动加载明细(含库存)并计算齐套性 // 监听 BOM 选择变化,自动加载明细(含库存)并计算齐套性
watch(selectedBomNo, async (newBomNo) => { watch(selectedBomNo, async (newKey) => {
if (!newBomNo) { if (!newKey) {
currentBomDetail.value = [] currentBomDetail.value = []
return return
} }
// ★ 组合 key 拆解为 bom_no + version按选中版本精确拉取明细
const [bomNo, version] = newKey.split('###')
try { try {
const detailRes: any = await getBomWithStock(newBomNo) const detailRes: any = await getBomWithStock(bomNo, version)
currentBomDetail.value = detailRes.data?.children || [] currentBomDetail.value = detailRes.data?.children || []
} catch (e) { } catch (e) {
ElMessage.error('加载 BOM 明细失败') ElMessage.error('加载 BOM 明细失败')
@ -734,7 +818,9 @@ const confirmBomAdd = async () => {
if (currentBomDetail.value.length === 0) { if (currentBomDetail.value.length === 0) {
try { try {
const detailRes: any = await getBomWithStock(selectedBomNo.value) // ★ 组合 key 拆解为 bom_no + version按选中版本精确拉取明细
const [bomNo, version] = (selectedBomNo.value || '').split('###')
const detailRes: any = await getBomWithStock(bomNo, version)
currentBomDetail.value = detailRes.data?.children || [] currentBomDetail.value = detailRes.data?.children || []
} catch (e) { } catch (e) {
ElMessage.error('获取 BOM 详情失败') ElMessage.error('获取 BOM 详情失败')
@ -792,6 +878,51 @@ const confirmBomAdd = async () => {
} }
} }
// ★ BOM 清单打印(复用与 confirmPrint 相同的 iframe 打印机制,与出库选单/借库选单一致)
const printBomChecklist = () => {
if (bomDetailList.value.length === 0) return ElMessage.warning('请先选择 BOM 并加载清单')
bomPrintTime.value = new Date().toLocaleString('zh-CN')
setTimeout(() => {
const printElement = document.getElementById('bom-print-area')
if (!printElement) return
const iframe = document.createElement('iframe')
iframe.style.position = 'fixed'
iframe.style.right = '0'
iframe.style.bottom = '0'
iframe.style.width = '0'
iframe.style.height = '0'
iframe.style.border = '0'
document.body.appendChild(iframe)
const iframeDoc = iframe.contentWindow?.document
if (!iframeDoc) return
iframeDoc.open()
iframeDoc.write('<html><head><title>BOM 借库清单</title></head><body></body></html>')
iframeDoc.close()
const style = `
body { font-family: 'Microsoft YaHei', sans-serif; }
.print-bom-area { display: block !important; padding: 20px; }
.print-table { width: 100%; border-collapse: collapse; }
.print-table th, .print-table td { border: 1px solid #000; padding: 6px 8px; font-size: 13px; text-align: center; }
.print-table th { background: #f0f0f0; }
`;
const styleEl = iframeDoc.createElement('style')
styleEl.textContent = style
iframeDoc.head.appendChild(styleEl)
iframeDoc.body.appendChild(printElement.cloneNode(true))
setTimeout(() => {
iframe.contentWindow?.focus()
iframe.contentWindow?.print()
setTimeout(() => document.body.removeChild(iframe), 1000)
}, 500)
}, 300)
}
// 主界面数量变更逻辑 // 主界面数量变更逻辑
const handleMainQuantityChange = (val: number | undefined, row: any) => { const handleMainQuantityChange = (val: number | undefined, row: any) => {
if (val === 0) { if (val === 0) {
@ -900,6 +1031,8 @@ const openRequestDialog = () => {
borrowerName.value = userStore.user?.username || '' borrowerName.value = userStore.user?.username || ''
requestRemark.value = '' requestRemark.value = ''
requestApproverId.value = null requestApproverId.value = null
requestExpectedReturn.value = ''
requestIndefinite.value = false
loadApprovers() loadApprovers()
requestDialogVisible.value = true requestDialogVisible.value = true
} }
@ -915,7 +1048,27 @@ const loadApprovers = async () => {
} }
} }
// ★ 长期借用勾选:勾选时清空已选的归还日期
const handleRequestIndefiniteChange = (val: boolean) => {
if (val) requestExpectedReturn.value = ''
}
// ★ 确认提交借库申请 // ★ 确认提交借库申请
// ★ 将 BOM 缺货清单拼接进申请原因(与出库选单一致)
const buildRemarkWithShortage = (baseRemark: string): string => {
const parts: string[] = []
if (baseRemark) parts.push(baseRemark)
// ★ 用当前实时计算的缺货清单(需量 - 当前库存),而非 localStorage 历史记录
const shortageItems = bomDetailList.value.filter((i: any) => i.shortage > 0)
if (shortageItems.length > 0) {
const items = shortageItems.map((i: any) => `${i.name}(${i.shortage}个)`).join('、')
parts.push(`[BOM欠料待补] ${items}`)
}
return parts.join(' | ')
}
const confirmSubmitRequest = async () => { const confirmSubmitRequest = async () => {
requestSubmitting.value = true requestSubmitting.value = true
try { try {
@ -924,9 +1077,11 @@ const confirmSubmitRequest = async () => {
name: item.name || '', name: item.name || '',
spec_model: item.standard || '', spec_model: item.standard || '',
warehouse_location: item.warehouse_location || '', warehouse_location: item.warehouse_location || '',
quantity: item.export_quantity || 0 quantity: item.export_quantity || 0,
expected_return_time: requestIndefinite.value ? null : requestExpectedReturn.value,
is_indefinite: requestIndefinite.value
})), })),
remark: requestRemark.value.trim(), remark: buildRemarkWithShortage(requestRemark.value.trim()),
approver_id: requestApproverId.value approver_id: requestApproverId.value
} }

View File

@ -76,6 +76,15 @@
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="归还期限" width="130" align="center">
<template #default="{ row }">
<span v-if="getReturnDeadline(row)" :style="row?.items?.[0]?.is_indefinite ? 'color:#E6A23C;font-weight:bold;' : ''">
{{ getReturnDeadline(row) }}
</span>
<span v-else style="color: #909399;">未填写</span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="申请时间" width="170" /> <el-table-column prop="created_at" label="申请时间" width="170" />
<el-table-column label="状态" width="100" align="center"> <el-table-column label="状态" width="100" align="center">
@ -219,6 +228,14 @@ const getApplicantName = (id: number | null) => {
return userNameCache.value[id] ?? `用户 #${id}` return userNameCache.value[id] ?? `用户 #${id}`
} }
// 从申请单明细快照中读取归还期限(旧申请单无此字段时返回空串)
const getReturnDeadline = (row: any) => {
const first = row?.items?.[0]
if (first?.is_indefinite) return '长期借用'
if (first?.expected_return_time) return first.expected_return_time
return ''
}
const getApproverName = (id: number | null) => { const getApproverName = (id: number | null) => {
if (!id) return '-' if (!id) return '-'
return userNameCache.value[id] ?? `用户 #${id}` return userNameCache.value[id] ?? `用户 #${id}`

View File

@ -1473,7 +1473,7 @@ const isArraysEqual = (a: any[], b: any[]): boolean => {
const buildPartialPayload = (current: any, original: any): any => { const buildPartialPayload = (current: any, original: any): any => {
const payload: any = { id: current.id }; const payload: any = { id: current.id };
const compareFields = ['name', 'commonName', 'category', 'type', 'spec', 'unit', 'visibilityLevel', 'isEnabled', 'isInspectionRequired', 'generalImage', 'generalManual', 'companyName', 'productImageRemark', 'manualLinkRemark']; const compareFields = ['name', 'commonName', 'category', 'type', 'spec', 'unit', 'referencePrice', 'visibilityLevel', 'isEnabled', 'isInspectionRequired', 'generalImage', 'generalManual', 'companyName', 'productImageRemark', 'manualLinkRemark'];
for (const key of compareFields) { for (const key of compareFields) {
const currentVal = current[key]; const currentVal = current[key];

View File

@ -268,7 +268,7 @@
<div id="bom-print-area" class="print-bom-area" style="display:none;"> <div id="bom-print-area" class="print-bom-area" style="display:none;">
<h2 style="text-align:center; margin:0 0 8px;">BOM 出库清单</h2> <h2 style="text-align:center; margin:0 0 8px;">BOM 出库清单</h2>
<p style="text-align:center; margin:0 0 16px; font-size:13px;"> <p style="text-align:center; margin:0 0 16px; font-size:13px;">
BOM: <b>{{ selectedBomNo }}</b> 套数: <b>{{ bomSets }}</b> 生成时间: {{ bomPrintTime }} BOM: <b>{{ selectedBomLabel }}</b> 套数: <b>{{ bomSets }}</b> 生成时间: {{ bomPrintTime }}
</p> </p>
<table class="print-table"> <table class="print-table">
<thead> <thead>
@ -296,6 +296,9 @@
<!-- 欠料待补签收区仅当有缺货时打印此联 --> <!-- 欠料待补签收区仅当有缺货时打印此联 -->
<div v-if="bomShortageCount > 0" style="margin-top: 20px; border-top: 2px dashed #000; padding-top: 10px;"> <div v-if="bomShortageCount > 0" style="margin-top: 20px; border-top: 2px dashed #000; padding-top: 10px;">
<p style="text-align:center; margin:0 0 8px; font-size:13px;">
BOM: <b>{{ selectedBomLabel }}</b> 套数: <b>{{ bomSets }}</b> 生成时间: {{ bomPrintTime }}
</p>
<p style="font-weight: bold; font-size: 14px;"> 欠料待补清单请补料后在此签字</p> <p style="font-weight: bold; font-size: 14px;"> 欠料待补清单请补料后在此签字</p>
<table class="print-table" style="margin-top: 6px;"> <table class="print-table" style="margin-top: 6px;">
<thead> <thead>
@ -560,7 +563,8 @@ const treeData = computed(() => {
label: `${group.category} (${group.count})`, label: `${group.category} (${group.count})`,
disabled: true, // 禁止选中分类本身 disabled: true, // 禁止选中分类本身
children: (group.items || []).map((b: any) => ({ children: (group.items || []).map((b: any) => ({
value: b.bom_no, // ★ 用 bom_no + version 组合作为唯一 value避免同一产品多版本时回显错乱
value: `${b.bom_no}###${b.version}`,
label: `${b.bom_no} - ${b.parent_name} - ${b.version}` label: `${b.bom_no} - ${b.parent_name} - ${b.version}`
})) }))
})) }))
@ -635,6 +639,12 @@ const bomPrintTime = ref('')
const bomAvailableCount = computed(() => bomDetailList.value.filter(i => i.shortage === 0).length) const bomAvailableCount = computed(() => bomDetailList.value.filter(i => i.shortage === 0).length)
const bomShortageCount = computed(() => bomDetailList.value.filter(i => i.shortage > 0).length) const bomShortageCount = computed(() => bomDetailList.value.filter(i => i.shortage > 0).length)
// BOM 选择显示标签bom_no###version → "bom_no (version)"
const selectedBomLabel = computed(() => {
const [no, ver] = (selectedBomNo.value || '').split('###')
return ver ? `${no} (${ver})` : no
})
// --- 辅助方法 --- // --- 辅助方法 ---
const getTypeTag = (type: string) => { const getTypeTag = (type: string) => {
switch (type) { switch (type) {
@ -808,35 +818,21 @@ const openBomSelect = async () => {
category, count: items.length, items category, count: items.length, items
})) }))
// ★ 提示有历史缺货记录的 BOM防止下次重复出/漏出
const pendingBoms = Object.keys(bomShortageRecords.value).filter(no =>
flatItems.some(i => i.bom_no === no)
)
if (pendingBoms.length > 0) {
ElMessageBox.confirm(
`检测到以下 BOM 存在未完成的缺货记录(上次按 BOM 出库时缺货):\n\n` +
pendingBoms.map(no => {
const rec = bomShortageRecords.value[no]
return `${no}${rec.items.length} 种缺货,${rec.time}`
}).join('\n') +
`\n\n建议先补货后再出库避免漏出。`,
'存在未完成出库',
{ confirmButtonText: '知道了', cancelButtonText: '忽略', type: 'warning' }
).catch(() => {})
}
} catch (e) { } catch (e) {
ElMessage.error('加载 BOM 列表失败') ElMessage.error('加载 BOM 列表失败')
} }
} }
// 监听 BOM 选择变化,自动加载明细(含库存)并计算齐套性 // 监听 BOM 选择变化,自动加载明细(含库存)并计算齐套性
watch(selectedBomNo, async (newBomNo) => { watch(selectedBomNo, async (newKey) => {
if (!newBomNo) { if (!newKey) {
currentBomDetail.value = [] currentBomDetail.value = []
return return
} }
// ★ 组合 key 拆解为 bom_no + version按选中版本精确拉取明细
const [bomNo, version] = newKey.split('###')
try { try {
const detailRes: any = await getBomWithStock(newBomNo) const detailRes: any = await getBomWithStock(bomNo, version)
currentBomDetail.value = detailRes.data?.children || [] currentBomDetail.value = detailRes.data?.children || []
} catch (e) { } catch (e) {
ElMessage.error('加载 BOM 明细失败') ElMessage.error('加载 BOM 明细失败')
@ -849,7 +845,9 @@ const confirmBomAdd = async () => {
if (currentBomDetail.value.length === 0) { if (currentBomDetail.value.length === 0) {
try { try {
const detailRes: any = await getBomWithStock(selectedBomNo.value) // ★ 组合 key 拆解为 bom_no + version按选中版本精确拉取明细
const [bomNo, version] = (selectedBomNo.value || '').split('###')
const detailRes: any = await getBomWithStock(bomNo, version)
currentBomDetail.value = detailRes.data?.children || [] currentBomDetail.value = detailRes.data?.children || []
} catch (e) { } catch (e) {
ElMessage.error('获取 BOM 详情失败') ElMessage.error('获取 BOM 详情失败')

View File

@ -416,11 +416,25 @@ const loadApprovalRequests = async () => {
} }
} }
// ★ 切换审批单时:清空购物车和签名,防止跨单据污染 // ★ 切换审批单时:带出申请时填写的归还日期/长期借用,并清空购物车和签名,防止跨单据污染
const handleApprovalChange = (val: number | null) => { const handleApprovalChange = (val: number | null) => {
if (!val) { if (!val) {
selectedApprovalId.value = null selectedApprovalId.value = null
} }
// 从申请单明细快照中带出"预计归还日期"(申请时通过 items_json 存储,无需改数据库)
const req = approvalRequests.value.find(r => r.id === val)
const firstItem = req?.items?.[0]
if (firstItem?.is_indefinite) {
isIndefinite.value = true
form.expected_return_time = ''
} else if (firstItem?.expected_return_time) {
isIndefinite.value = false
form.expected_return_time = firstItem.expected_return_time
} else {
// 旧申请单/未填时间:保持手动选择
isIndefinite.value = false
form.expected_return_time = ''
}
cartItems.value = [] cartItems.value = []
signatureFile.value = null signatureFile.value = null
signaturePreviewUrl.value = '' signaturePreviewUrl.value = ''