新增耗材入库-ai批量入库

This commit is contained in:
gyq
2025-11-28 18:02:17 +08:00
parent 89f9283163
commit abcec9f62b
9 changed files with 494 additions and 58 deletions

View File

@@ -149,6 +149,38 @@ const AuthAPI = {
method: "post",
data,
});
},
// 入库单识别
stockOcr(data: any) {
return request<any, Responseres>({
url: `${baseURL}/stock/ocr`,
method: "post",
data,
});
},
// ocr识别结果
ocrResult(params: any) {
return request<any, Responseres>({
url: `${baseURL}/stock/ocrResult`,
method: "get",
params,
});
},
// 耗材入库
stockIn(data: any) {
return request<any, Responseres>({
url: `${baseURL}/stock/in`,
method: "POST",
data,
});
},
// 供应商-列表
vendorList(params: any) {
return request<any, Responseres>({
url: `${baseURL}/vendor/list`,
method: "get",
params,
});
}
};

View File

@@ -1,17 +1,8 @@
<!-- 单图上传组件 -->
<template>
<el-upload
v-model="modelValue"
class="single-upload"
list-type="picture-card"
:show-file-list="false"
:accept="props.accept"
:before-upload="handleBeforeUpload"
:http-request="handleUpload"
:on-success="onSuccess"
:on-error="onError"
multiple
>
<el-upload v-model="modelValue" class="single-upload" list-type="picture-card" :show-file-list="false"
:accept="props.accept" :before-upload="handleBeforeUpload" :http-request="handleUpload" :on-success="onSuccess"
:on-error="onError" multiple :before-remove="emits('clear')">
<template #default>
<el-image v-if="modelValue" :src="modelValue" />
<el-icon v-if="modelValue" class="single-upload__delete-btn" @click.stop="handleDelete">
@@ -120,6 +111,7 @@ function handleBeforeUpload(file: UploadRawFile) {
*/
function handleUpload(options: UploadRequestOptions) {
return new Promise((resolve, reject) => {
emits("upload", options);
const file = options.file;
const formData = new FormData();
@@ -147,7 +139,7 @@ function handleDelete() {
modelValue.value = "";
}
const emits = defineEmits(["onSuccess"]);
const emits = defineEmits(["onSuccess", 'upload', 'clear']);
/**
* 上传成功回调
*

View File

@@ -681,13 +681,15 @@ export default {
shopId: this.shopInfo.id
});
this.trade = res;
this.tradeLoading = false;
this.tradeSale = res.sale;
this.tradeVip = res.vip;
this.tradeCount = res.count;
} catch (error) {
console.log(error);
}
setTimeout(() => {
this.tradeLoading = false;
}, 500);
},
lineChartTypeChange(i) {
this.lineChartType = i;

View File

@@ -8,9 +8,8 @@
<el-form :model="query" inline label-position="left">
<template v-if="orderType == 2">
<el-form-item>
<el-input placeholder="商品名称" v-model="query.productName" />
<el-input placeholder="商品名称" v-model="query.productName" clearable />
</el-form-item>
<el-form-item v-if="isHeadShop == 1 && loginType == 0">
<el-select v-model="shopId" placeholder="选择分店" style="width: 200px; margin-right: 10px"
@change="getCategory">
@@ -35,12 +34,12 @@
<el-radio-button value="custom">自定义</el-radio-button>
</el-radio-group>
<el-date-picker class="u-m-l-10" v-model="query.createdAt" type="daterange" range-separator="至"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始日期" end-placeholder="结束日期" value-format="YYYY-MM-DD" :disabled-date="disabledDate"
v-if="timeValue == 'custom'"></el-date-picker>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="getTableData">查询</el-button>
<el-button @click="resetHandle">重置</el-button>
<el-button type="primary" icon="Search" :loading="tableData.loading" @click="getTableData">查询</el-button>
<el-button icon="Refresh" :loading="tableData.loading" @click="resetHandle">重置</el-button>
<el-button icon="download" v-loading="downloadLoading" @click="downloadHandle">
<span v-if="!downloadLoading">导出Excel</span>
<span v-else>下载中...</span>
@@ -205,7 +204,7 @@ export default {
orderType: "2",
categorys: [],
query: {
createdAt: [],
createdAt: [dayjs().format("YYYY-MM-DD"), dayjs().format("YYYY-MM-DD")],
productName: "",
prodCategoryId: "",
},
@@ -225,6 +224,12 @@ export default {
isHeadShop: JSON.parse(localStorage.getItem("userInfo")).isHeadShop,
loginType: localStorage.getItem("loginType"),
shopInfo: JSON.parse(localStorage.getItem("userInfo")),
disabledDate: (time) => {
// dayjs().startOf('day'):获取今天的 00:00:00
// dayjs(time):将原生 Date 转为 dayjs 对象
// isAfter判断目标日期是否在今天之后
return dayjs(time).isAfter(dayjs().startOf('day'));
}
};
},
filters: {
@@ -268,9 +273,9 @@ export default {
// 获取订单汇总
async daycount() {
try {
if (this.query.createdAt[1]) {
this.query.createdAt.splice(1, 1, this.query.createdAt[1].replace("00:00:00", "23:59:59"))
}
// if (this.query.createdAt[1]) {
// this.query.createdAt.splice(1, 1, this.query.createdAt[1].replace("00:00:00", "23:59:59"))
// }
const res = await saleSummaryApi.count({
beginDate: this.query.createdAt[0],
endDate: this.query.createdAt[1],
@@ -289,9 +294,9 @@ export default {
async downloadHandle() {
try {
this.downloadLoading = true;
if (this.query.createdAt[1]) {
this.query.createdAt.splice(1, 1, this.query.createdAt[1].replace("00:00:00", "23:59:59"))
}
// if (this.query.createdAt[1]) {
// this.query.createdAt.splice(1, 1, this.query.createdAt[1].replace("00:00:00", "23:59:59"))
// }
const file = await saleSummaryApi.export({
beginDate: this.query.createdAt[0],
endDate: this.query.createdAt[1],
@@ -307,7 +312,7 @@ export default {
},
// 重置查询
resetHandle() {
this.timeValue = "";
this.timeValue = 'today';
this.query = { ...this.resetQuery };
this.page = 1;
this.getTableData();
@@ -327,9 +332,9 @@ export default {
this.tableData.loading = true;
try {
this.daycount();
if (this.query.createdAt[1]) {
this.query.createdAt.splice(1, 1, this.query.createdAt[1].replace("00:00:00", "23:59:59"))
}
// if (this.query.createdAt[1]) {
// this.query.createdAt.splice(1, 1, this.query.createdAt[1].replace("00:00:00", "23:59:59"))
// }
const res = await saleSummaryApi.page({
page: this.tableData.page,
size: this.tableData.size,
@@ -347,11 +352,17 @@ export default {
} catch (error) {
console.log(error);
}
this.tableData.loading = false;
setTimeout(() => {
this.tableData.loading = false;
}, 500);
},
// 切换时间
timeChange(e) {
this.query.createdAt = formatDateRange(e)
if (e !== 'custom') {
this.query.createdAt = formatDateRange(e)
} else {
this.query.createdAt = []
}
},
},
};

View File

@@ -7,12 +7,8 @@
</el-form-item>
<el-form-item label="耗材分类" prop="consGroupId">
<el-select v-model="form.consGroupId" placeholder="请选择耗材分类" style="width: 200px">
<el-option
v-for="option in consGroups"
:key="option.conTypeId"
:label="option.label"
:value="option.id"
></el-option>
<el-option v-for="option in consGroups" :key="option.conTypeId" :label="option.label"
:value="option.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="耗材价格" prop="price">
@@ -24,18 +20,11 @@
<el-form-item label="状态">
<el-switch v-model="form.status" :active-value="1" :inactive-value="0"></el-switch>
</el-form-item>
<el-form-item label="单位" prop="conUnit">
<el-input v-model="form.conUnit" placeholder="请输入单位"></el-input>
</el-form-item>
<el-alert
class="u-m-t-10 u-m-b-10"
title="提示"
description="换算值为第二单位*第二单位转换数量=第一单位"
type="warning"
show-icon
:closable="false"
></el-alert>
<el-alert class="u-m-t-10 u-m-b-10" title="提示" description="换算值为第二单位*第二单位转换数量=第一单位" type="warning" show-icon
:closable="false"></el-alert>
<el-form-item label="预警值">
<el-input-number v-model="form.conWarning" placeholder="请输入耗材预警值"></el-input-number>
</el-form-item>
@@ -46,10 +35,7 @@
<el-input v-model="form.conUnitTwo" placeholder="请输入第二单位"></el-input>
</el-form-item>
<el-form-item label="第二单位转换数量" prop="conUnitTwoConvert">
<el-input-number
v-model="form.conUnitTwoConvert"
placeholder="第二单位转换数量"
></el-input-number>
<el-input-number v-model="form.conUnitTwoConvert" placeholder="第二单位转换数量"></el-input-number>
</el-form-item>
<el-form-item label="默认入库单位" prop="defaultUnit">
<el-input v-model="form.defaultUnit" placeholder="请输入默认入库单位"></el-input>

View File

@@ -0,0 +1,393 @@
<!-- AI入库 -->
<template>
<div>
<el-dialog title="批量入库" width="1000px" v-model="visible" @close="dialogCloseHandle">
<div class="content">
<div class="title_wrap">
<span class="t">第一步上传图片</span>
</div>
<div class="upload_wrap" v-loading="uploadLoading">
<div class="upload_btn">
<SingleImageUpload v-model="imgUrl" @upload="uploadLoading = true" @on-success="uploadSuccess"
@clear="resId = null" />
</div>
<div class="btn">
<el-button type="primary" :disabled="resId === null" @click="startCheckOcrRes">开始解析</el-button>
</div>
</div>
<div class="title_wrap">
<span class="t">第二步编辑信息</span>
</div>
<div class="table" v-if="form.bodyList">
<el-form :model="form" label-width="120px" label-position="top">
<el-form-item label="供应商信息">
<el-select v-model="form.vendorId" style="width: 200px;" :disabled="stockInStatus">
<el-option v-for="item in vendorList" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="耗材信息">
<div class="header_wrap">
<div class="item">
<span class="tb">{{ form.bodyList.length }}种耗材</span>
<span class="tb">金额合计{{multiplyAndFormat(form.bodyList.reduce((sum, item) => sum +
(item.purchasePrice || 0) *
(item.inOutNumber || 0), 0), 1)}}</span>
</div>
<div class="item">
<el-button type="primary" @click="consumableListRef.show(form.bodyList)"
v-if="!stockInStatus">选择耗材</el-button>
</div>
</div>
<el-table :data="form.bodyList" border stripe width="100%" v-loading="confirmLoading">
<el-table-column prop="conName" label="耗材名称">
<template v-slot="scope">
<el-input v-model="scope.row.conName" :maxlength="8" :disabled="stockInStatus"></el-input>
</template>
</el-table-column>
<el-table-column prop="purchasePrice" label="单价">
<template v-slot="scope">
<el-input v-model="scope.row.purchasePrice" :maxlength="8"
@input="e => scope.row.purchasePrice = filterNumberInput(e)" :disabled="stockInStatus"></el-input>
</template>
</el-table-column>
<el-table-column prop="unitName" label="单位">
<template v-slot="scope">
<el-input v-model="scope.row.unitName" :maxlength="8" :disabled="stockInStatus"></el-input>
</template>
</el-table-column>
<el-table-column prop="inOutNumber" label="数量">
<template v-slot="scope">
<el-input v-model="scope.row.inOutNumber" @input="e => scope.row.inOutNumber = filterNumberInput(e)"
:disabled="stockInStatus"></el-input>
</template>
</el-table-column>
<el-table-column prop="subTotal" label="小计">
<template v-slot="scope">
{{ multiplyAndFormat(scope.row.purchasePrice || 0, scope.row.inOutNumber || 0) }}
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template v-slot="scope">
<div v-if="stockInStatus">
<el-text type="success" v-if="scope.row.conId">入库成功</el-text>
<div class="column" v-else>
<div>
<el-text type="danger">入库失败</el-text>
</div>
<div>
<el-text type="info" size="small">失败原因{{ scope.row.failReason }}</el-text>
</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template v-slot="scope">
<el-button type="danger" link @click="form.bodyList.splice(scope.$index, 1)"
v-if="!stockInStatus">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-form-item>
</el-form>
<div class="result_wrap" v-if="stockInStatus">
上传完成 {{ form.bodyList.length }}条数据其中成功
<el-text type="success">{{form.bodyList.filter(item => item.conId).length}} </el-text> 失败 <el-text
type="danger">{{form.bodyList.filter(item => !item.conId).length}} </el-text>
</div>
</div>
<div class="tips" v-else>
<el-text type="danger">请解析后使用</el-text>
</div>
</div>
<template #footer>
<div class="dialog-footer" v-if="form.bodyList && !stockInStatus">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" :loading="confirmLoading" @click="submitHandle">提交</el-button>
</div>
</template>
</el-dialog>
<el-dialog title="扫描结果查询中..." width="500px" v-model="checkLoading" :show-close="false"
:close-on-click-modal="false">
<div class="loading_wrap" ref="loadingWrap" v-loading="checkLoading" :element-loading-text="loadingText"></div>
<template #footer>
<div class="dialog-footer">
<el-button type="danger" @click="closeCheckOcrHandle">取消查询</el-button>
</div>
</template>
</el-dialog>
<consumableList ref="consumableListRef" @success="consumableListRes" />
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from "vue";
import SingleImageUpload from "@/components/Upload/SingleImageUpload.vue"
import productApi from "@/api/product/index";
import { ElMessage } from "element-plus";
import { filterNumberInput, multiplyAndFormat } from '@/utils'
import consumableList from "../../operation_in/components/consumableList.vue";
const consumableListRef = ref(null);
function consumableListRes(e) {
console.log(e);
if (e.length) {
let arr = e.map(item => ({
id: item.id,
conId: item.id,
conName: item.conName,
purchasePrice: item.price,
unitName: item.conUnit,
inOutNumber: 1,
}));
form.value.bodyList.push(...arr);
}
}
const emits = defineEmits(['update']);
const visible = ref(false);
const show = () => {
visible.value = true;
};
const dialogCloseHandle = () => {
emits('update');
resId.value = null;
form.value = {};
stockInStatus.value = false;
imgUrl.value = "";
};
// 上传图片
let resId = ref(null);
const uploadLoading = ref(false);
const imgUrl = ref("");
async function uploadSuccess(url) {
try {
imgUrl.value = url;
uploadLoading.value = true;
resId.value = await productApi.stockOcr({ url: imgUrl.value });
} catch (error) {
console.error('上传图片失败', error);
}
setTimeout(() => {
uploadLoading.value = false;
}, 500);
}
// 查询OCR结果
const form = ref({})
function startCheckOcrRes() {
stockInStatus.value = false;
startQueryInterval()
.then((res) => {
console.log('查询成功', JSON.stringify(res));
form.value = res;
})
.catch((error) => {
console.error('查询失败', error);
});
}
// 启动查询方法每5秒查询一次五分钟后超时停止查询
const checkLoading = ref(false);
const speed = 10000; // 查询间隔时间,单位毫秒
const timeout = 300000; // 超时时间,单位毫秒
// const timeout = 15000; // 超时时间,单位毫秒
const interval = ref(null);
const checkNumber = ref(0);
const loadingText = computed(() => `${speed / 1000}秒查询1次已查询${checkNumber.value}`);
const loadingWrap = ref(null);
function updateOverlayText() {
nextTick(() => {
try {
const el = loadingWrap.value && loadingWrap.value.querySelector && loadingWrap.value.querySelector('.el-loading-text');
if (el) el.textContent = loadingText.value;
} catch (e) {
// ignore
}
});
}
watch(loadingText, () => {
if (checkLoading.value) updateOverlayText();
});
watch(checkLoading, (val) => {
if (val) updateOverlayText();
});
function startQueryInterval() {
return new Promise((resolve, reject) => {
if (!resId.value) {
reject(new Error('无效的 resId无法查询'));
return;
}
// 重置计数并显示 loading
checkNumber.value = 0;
checkLoading.value = true;
updateOverlayText();
const startTime = Date.now();
interval.value = null;
async function checkOnce() {
try {
checkNumber.value++;
console.log('ocr checkNumber:', checkNumber.value);
checkLoading.value = true;
const res = await productApi.ocrResult({ id: resId.value });
if (res && res.batchNo) {
ElMessage.success('查询成功');
checkLoading.value = false;
clearInterval(interval.value);
resolve(res);
return;
}
// 如果还未超时,则继续等待下一轮查询
if (Date.now() - startTime >= timeout) {
checkLoading.value = false;
ElMessage.error('查询超时');
clearInterval(interval.value);
reject(new Error('查询超时'));
}
} catch (error) {
ElMessage.error('查询失败');
checkLoading.value = false;
clearInterval(interval.value);
reject(error);
}
}
// 立即执行一次查询,然后按间隔继续查询
checkOnce();
interval.value = setInterval(checkOnce, speed);
});
}
// 取消查询
function closeCheckOcrHandle() {
checkLoading.value = false;
clearInterval(interval.value);
// 清空 overlay 文本(如果存在)以避免残留描述
nextTick(() => {
try {
const el = loadingWrap.value && loadingWrap.value.querySelector && loadingWrap.value.querySelector('.el-loading-text');
if (el) el.textContent = '';
} catch (e) { }
});
}
// 供应商列表
const vendorList = ref([]);
async function vendorListAjax() {
try {
vendorList.value = await productApi.vendorList();
} catch (error) {
console.log(error);
}
}
// 最终提交
const confirmLoading = ref(false);
const stockInStatus = ref(false);
async function submitHandle() {
try {
confirmLoading.value = true;
const res = await productApi.stockIn(form.value)
form.value = res;
stockInStatus.value = true;
ElMessage.success('提交成功');
} catch (error) {
console.error('提交失败', error);
}
setTimeout(() => {
confirmLoading.value = false;
// visible.value = false;
}, 500);
}
defineExpose({
show,
});
onMounted(() => {
vendorListAjax();
});
</script>
<style scoped lang="scss">
.content {
.title_wrap {
background-color: #F8F8F8;
height: 54px;
display: flex;
align-items: center;
padding: 0 42px;
.t {
font-size: 16px;
color: #333;
}
}
.upload_wrap {
display: flex;
justify-content: space-between;
padding: 14px 0;
.upload_btn {
width: 152px;
height: 152px;
}
}
}
.loading_wrap {
height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.table {
width: 100%;
padding-top: 14px;
}
.result_wrap {
padding: 14px 0;
display: flex;
justify-content: center;
}
.tips {
padding: 14px 0;
display: flex;
justify-content: center;
}
.column {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.header_wrap {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 14px;
.tb {
font-size: 16px;
font-weight: bold;
}
}
</style>

View File

@@ -82,6 +82,11 @@ const contentConfig: IContentConfig = {
name: "reportinglosses",
auth: "",
},
{
text: "批量入库",
name: "batchWarehousing",
auth: "",
},
],
defaultToolbar: ["refresh", "filter", "search"],
cols: [

View File

@@ -83,10 +83,14 @@
<add-haocai ref="refAddHaocai" @refresh="refresh" />
<!-- 耗材盘点 -->
<addConsTakin ref="refAddHaocaiTakin" @success="refresh" />
<!-- AI入库 -->
<ai-entry-dialog ref="aiEntryDialogRef" @update="
contentRef?.fetchPageData()" />
</div>
</template>
<script setup lang="ts">
import aiEntryDialog from "./components/aiEntryDialog.vue";
import addHaocai from "./components/add-haocai.vue";
import dataTongji from "./components/DataStatistics.vue";
import addConsTakin from "./components/addConsTakin.vue";
@@ -101,6 +105,12 @@ import searchConfig from "./config/search";
import { returnOptionsLabel } from "./config/config";
import { isSyncStatus } from "@/utils/index";
const aiEntryDialogRef = ref<any>(null);
function showAiEntryDialog() {
aiEntryDialogRef.value?.show?.();
}
const router = useRouter();
const {
searchRef,
@@ -164,7 +174,7 @@ watch(
);
//耗材盘点
const refAddHaocaiTakin = ref();
const refAddHaocaiTakin = ref<any>(null);
function refAddHaocaiTakinShow(item: any, type: string) {
console.log(item);
if (type === "manual-in") {
@@ -172,14 +182,14 @@ function refAddHaocaiTakinShow(item: any, type: string) {
return;
}
if (type === "delete") {
refAddHaocaiTakin.value.show(item, type);
refAddHaocaiTakin.value?.show?.(item, type);
return;
}
if (type === "consumables") {
refAddHaocaiTakin.value.show(item, type);
refAddHaocaiTakin.value?.show?.(item, type);
return;
}
refAddHaocaiTakin.value.show(item);
refAddHaocaiTakin.value?.show?.(item);
}
function refresh() {
@@ -187,9 +197,9 @@ function refresh() {
contentRef.value?.fetchPageData();
getTongji(undefined);
}
const refAddHaocai = ref();
const refAddHaocai = ref<any>(null);
function refAddHaocaiOpen(item: any) {
refAddHaocai.value.open(item);
refAddHaocai.value?.open?.(item);
}
// 新增
async function handleAddClick() {
@@ -234,6 +244,10 @@ async function handleToolbarClick(name: string) {
router.push({ path: "/inventory/libraryrecords", query: { inOutItem: name } });
return;
}
if (name === "batchWarehousing") {
showAiEntryDialog();
return;
}
}
// 其他操作列
async function handleOperatClick(data: IOperatData) {

View File

@@ -35,6 +35,7 @@
</template>
<script setup>
import _ from "lodash";
import CouponLists from "./coup-lists.vue";
import { ref, toRaw } from "vue";
@@ -132,7 +133,7 @@ function open(data, index) {
console.log("data", data);
console.log("index", index);
if (data && data.name) {
form.value = { ...data };
form.value = _.cloneDeep(toRaw(data));
isedit.value = true;
dataIndex = index;
} else {