feat(promotion,stock): 商家出库单管理、库存流水查询及优惠券礼包推广二维码

This commit is contained in:
pikachu1995@126.com
2026-11-12 16:25:10 +08:00
parent 242d3a0f9f
commit 792229a11b
9 changed files with 1059 additions and 2 deletions

View File

@@ -0,0 +1,339 @@
<!--
商家端出库单列表页面
@author Bulbasaur
@since 2026-11-12
-->
<template>
<div class="search">
<el-card shadow="never">
<el-form ref="searchFormRef" :model="searchForm" inline label-width="80px">
<el-form-item label="出库单号">
<el-input
v-model="searchForm.outboundSn"
placeholder="请输入出库单号"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="dateRange"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
start-placeholder="开始时间"
end-placeholder="结束时间"
style="width: 360px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="mt_10" shadow="never">
<div class="order-tab">
<el-tabs v-model="currentStatus" @tab-click="onStatusTabClick">
<el-tab-pane
v-for="item in statusTabs"
:key="item.value"
:label="item.title"
:name="item.value"
/>
</el-tabs>
</div>
<div class="operation padding-row">
<el-button type="primary" @click="goCreate">新建出库单</el-button>
</div>
<el-table v-loading="loading" :data="data" border class="mt_10" style="width: 100%">
<el-table-column prop="outboundSn" label="出库单号" min-width="180" show-overflow-tooltip />
<el-table-column prop="reasonName" label="出库原因" min-width="120" show-overflow-tooltip />
<el-table-column prop="totalQuantity" label="出库数量" width="100" align="center" />
<el-table-column label="单据状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="statusTagType(row.status)">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="出库备注" min-width="160" show-overflow-tooltip />
<el-table-column prop="auditBy" label="审核人" width="110" show-overflow-tooltip />
<el-table-column prop="auditRemark" label="审核备注" min-width="140" show-overflow-tooltip />
<el-table-column prop="createTime" label="创建时间" width="170" />
<el-table-column label="操作" width="220" align="right" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="goDetail(row)">查看</el-button>
<template v-if="['DRAFT', 'REJECTED'].includes(row.status)">
<span class="op-split">|</span>
<el-button link type="primary" @click="goEdit(row)">编辑</el-button>
<span class="op-split">|</span>
<el-button link type="primary" @click="handleSubmit(row)">提交</el-button>
</template>
<template v-if="row.status === 'AUDITING'">
<span class="op-split">|</span>
<el-button link type="success" @click="openAuditDialog(row)">审核</el-button>
</template>
<template v-if="['DRAFT', 'AUDITING', 'REJECTED'].includes(row.status)">
<span class="op-split">|</span>
<el-button link type="danger" @click="handleCancel(row)">作废</el-button>
</template>
</template>
</el-table-column>
</el-table>
<div class="mt_10" style="display: flex; justify-content: flex-end">
<el-pagination
v-model:current-page="searchForm.pageNumber"
v-model:page-size="searchForm.pageSize"
:page-sizes="[10, 20, 50]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
size="small"
@current-change="getDataList"
@size-change="getDataList"
/>
</div>
</el-card>
<!-- 审核弹窗 -->
<el-dialog v-model="auditDialogVisible" title="出库单审核" width="460px" destroy-on-close>
<el-form ref="auditFormRef" :model="auditForm" :rules="auditRules" label-width="90px">
<el-form-item label="出库单号">
<span>{{ currentOutbound?.outboundSn }}</span>
</el-form-item>
<el-form-item label="出库数量">
<span>{{ currentOutbound?.totalQuantity }}</span>
</el-form-item>
<el-form-item label="审核结果" prop="pass">
<el-radio-group v-model="auditForm.pass">
<el-radio :label="true">通过(扣减库存)</el-radio>
<el-radio :label="false">驳回</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="审核意见" prop="auditRemark">
<el-input
v-model="auditForm.auditRemark"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请输入审核意见或驳回原因"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="auditDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="auditLoading" @click="confirmAudit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { useTable } from "@/utils/useTable";
import { Message, Modal } from "@/utils/message";
import { customRouterPush } from "@/utils/filters";
import {
getStockOutboundPage,
submitStockOutbound,
auditStockOutbound,
cancelStockOutbound,
} from "@/api/stock";
defineOptions({ name: "StockOutboundList" });
const searchFormRef = ref(null);
const dateRange = ref([]);
const currentStatus = ref("ALL");
const STATUS_MAP = {
DRAFT: { text: "草稿", tag: "info" },
AUDITING: { text: "待审核", tag: "warning" },
COMPLETED: { text: "已完成", tag: "success" },
REJECTED: { text: "已驳回", tag: "danger" },
CANCELLED: { text: "已作废", tag: "info" },
};
const statusTabs = computed(() => [
{ title: "全部", value: "ALL" },
{ title: "草稿", value: "DRAFT" },
{ title: "待审核", value: "AUDITING" },
{ title: "已完成", value: "COMPLETED" },
{ title: "已驳回", value: "REJECTED" },
{ title: "已作废", value: "CANCELLED" },
]);
function statusText(status) {
return STATUS_MAP[status]?.text || status || "-";
}
function statusTagType(status) {
return STATUS_MAP[status]?.tag || "info";
}
const {
loading,
data,
total,
searchForm,
getDataList,
handleSearch: search,
resetSearch: reset,
} = useTable(getStockOutboundPage, {
form: { outboundSn: "", status: "" },
immediate: false,
beforeRequest: (p) => {
const params = { ...p };
if (params.status === "ALL") {
delete params.status;
}
if (dateRange.value && dateRange.value.length === 2) {
params.startDate = dateRange.value[0];
params.endDate = dateRange.value[1];
}
return params;
},
});
onMounted(() => {
getDataList();
});
function handleSearch() {
search();
}
function resetSearch() {
dateRange.value = [];
currentStatus.value = "ALL";
searchForm.outboundSn = "";
searchForm.status = "";
reset();
}
function onStatusTabClick(tab) {
const name = tab.paneName;
if (name === "ALL") {
searchForm.status = "";
} else {
searchForm.status = name;
}
searchForm.pageNumber = 1;
getDataList();
}
function goCreate() {
customRouterPush({ name: "stock-outbound-operation" });
}
function goEdit(row) {
customRouterPush({
name: "stock-outbound-operation",
query: { id: row.id },
});
}
function goDetail(row) {
customRouterPush({
name: "stock-outbound-operation",
query: { id: row.id, readonly: 1 },
});
}
function handleSubmit(row) {
Modal.confirm({
title: "提交审核",
content: `确认提交出库单 [${row.outboundSn}] 进行审核?`,
onOk: () => {
submitStockOutbound(row.id).then((res) => {
if (res.success) {
Message.success("提交审核成功");
getDataList();
}
});
},
});
}
function handleCancel(row) {
Modal.confirm({
title: "作废出库单",
content: `确认作废出库单 [${row.outboundSn}]?作废后不可恢复。`,
onOk: () => {
cancelStockOutbound(row.id).then((res) => {
if (res.success) {
Message.success("作废成功");
getDataList();
}
});
},
});
}
// ========== 审核弹窗相关 ==========
const auditDialogVisible = ref(false);
const auditLoading = ref(false);
const auditFormRef = ref(null);
const currentOutbound = ref(null);
const auditForm = reactive({
pass: true,
auditRemark: "",
});
const auditRules = {
pass: [{ required: true, message: "请选择审核结果", trigger: "change" }],
auditRemark: [
{
validator: (rule, value, callback) => {
if (auditForm.pass === false && (!value || !value.trim())) {
callback(new Error("驳回时必须填写审核意见"));
} else {
callback();
}
},
trigger: "blur",
},
],
};
function openAuditDialog(row) {
currentOutbound.value = row;
auditForm.pass = true;
auditForm.auditRemark = "";
auditDialogVisible.value = true;
}
function confirmAudit() {
if (!auditFormRef.value) return;
auditFormRef.value.validate((valid) => {
if (!valid) return;
auditLoading.value = true;
auditStockOutbound(currentOutbound.value.id, {
pass: auditForm.pass,
auditRemark: auditForm.auditRemark,
})
.then((res) => {
if (res.success) {
Message.success(auditForm.pass ? "审核通过,库存已扣减" : "已驳回出库单");
auditDialogVisible.value = false;
getDataList();
}
})
.finally(() => {
auditLoading.value = false;
});
});
}
</script>
<style scoped>
.op-split {
margin: 0 4px;
color: #dcdfe6;
}
.order-tab :deep(.el-tabs__item) {
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,375 @@
<!--
~ 商家端出库单详情/新建/编辑页面
~ @author Bulbasaur
~ @since 2026-11-12
-->
<template>
<div class="stock-outbound-operation">
<el-card>
<template #header>
<div class="card-header flex flex-justify-between flex-align-center">
<span class="font-bold text-lg">{{ pageTitle }}</span>
<el-button @click="goBack">返回列表</el-button>
</div>
</template>
<!-- 详情只读展示 -->
<template v-if="readonly">
<el-descriptions :column="2" border>
<el-descriptions-item label="出库单号">{{ detail.outboundSn || "-" }}</el-descriptions-item>
<el-descriptions-item label="单据状态">
<el-tag :type="statusTag(detail.status)">{{ statusText(detail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="出库原因">{{ detail.reasonName || "-" }}</el-descriptions-item>
<el-descriptions-item label="出库总数量">{{ detail.totalQuantity ?? "-" }}</el-descriptions-item>
<el-descriptions-item label="制单人">{{ detail.createBy || "-" }}</el-descriptions-item>
<el-descriptions-item label="制单时间">{{ detail.createTime || "-" }}</el-descriptions-item>
<el-descriptions-item label="审核人">{{ detail.auditBy || "-" }}</el-descriptions-item>
<el-descriptions-item label="审核时间">{{ detail.auditTime || "-" }}</el-descriptions-item>
<el-descriptions-item v-if="detail.auditRemark" label="审核备注" :span="2">
<span :class="detail.status === 'REJECTED' ? 'text-danger' : ''">{{ detail.auditRemark }}</span>
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "-" }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">出库商品明细</el-divider>
<el-table :data="items" border class="mt-4" style="width: 100%">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="goodsName" label="商品名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="skuId" label="SKU ID" width="180" />
<el-table-column prop="specs" label="规格" width="180" show-overflow-tooltip>
<template #default="{ row }">{{ row.specs || "-" }}</template>
</el-table-column>
<el-table-column prop="quantity" label="出库数量" width="120" align="center" />
<el-table-column prop="unit" label="单位" width="100" align="center">
<template #default="{ row }">{{ row.unit || "-" }}</template>
</el-table-column>
<el-table-column prop="remark" label="明细备注" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || "-" }}</template>
</el-table-column>
</el-table>
</template>
<!-- 新建/编辑表单 -->
<template v-else>
<el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="出库原因" prop="reasonId">
<el-select
v-model="form.reasonId"
placeholder="请选择出库原因"
class="w-full"
@change="handleReasonChange"
>
<el-option
v-for="item in reasonOptions"
:key="item.id"
:label="item.reason"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注" prop="remark">
<el-input
v-model="form.remark"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请输入出库单备注"
style="max-width: 600px"
/>
</el-form-item>
</el-form>
<div class="flex flex-justify-between flex-align-center mb-4">
<el-divider content-position="left" class="flex-1 mr-4">出库商品明细</el-divider>
<el-button type="primary" @click="openSkuPicker">选择商品</el-button>
</div>
<el-table :data="items" border style="width: 100%" empty-text="请点击上方按钮添加出库商品">
<el-table-column type="index" label="序号" width="60" align="center" />
<el-table-column prop="goodsName" label="商品名称" min-width="200" show-overflow-tooltip />
<el-table-column prop="skuId" label="SKU ID" width="180" />
<el-table-column prop="specs" label="规格" width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.specs || "-" }}</template>
</el-table-column>
<el-table-column label="出库数量" width="150" align="center">
<template #default="{ row }">
<el-input-number
v-model="row.quantity"
:min="1"
:max="999999"
:step="1"
step-strictly
controls-position="right"
style="width: 120px"
/>
</template>
</el-table-column>
<el-table-column label="单位" width="110" align="center">
<template #default="{ row }">
<el-input v-model="row.unit" maxlength="10" placeholder="件/个" />
</template>
</el-table-column>
<el-table-column label="明细备注" min-width="150">
<template #default="{ row }">
<el-input v-model="row.remark" maxlength="100" placeholder="备注" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ $index }">
<el-button link type="danger" @click="removeItem($index)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div class="summary mt-4 flex flex-justify-end font-14">
<span>出库品种数:<b class="text-primary">{{ items.length }}</b></span>
<span class="ml-6">合计出库数量:<b class="text-primary">{{ totalQuantity }}</b></span>
</div>
<div class="footer-actions mt-6 flex flex-justify-center">
<el-button @click="goBack">取消</el-button>
<el-button type="primary" :loading="submitLoading" @click="handleSave(false)">保存草稿</el-button>
<el-button type="success" :loading="submitLoading" @click="handleSave(true)">保存并提交</el-button>
</div>
</template>
</el-card>
<!-- 商品 SKU 选择弹窗 -->
<liliDialog ref="liliDialogRef" @selectedGoodsData="onGoodsSelected" />
</div>
</template>
<script setup>
import { computed, onMounted, reactive, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import liliDialog from "@/views/lili-dialog";
import {
createStockOutbound,
getStockOutboundDetail,
getStockReasonList,
submitStockOutbound,
updateStockOutbound,
} from "@/api/stock";
defineOptions({ name: "stock-outbound-operation" });
const route = useRoute();
const router = useRouter();
const readonly = ref(false);
const submitLoading = ref(false);
const formRef = ref(null);
const liliDialogRef = ref(null);
const form = reactive({
id: "",
reasonId: "",
reasonName: "",
remark: "",
});
const formRules = {
reasonId: [{ required: true, message: "请选择出库原因", trigger: "change" }],
};
const items = ref([]);
const detail = ref({});
const reasonOptions = ref([]);
const pageTitle = computed(() => {
if (readonly.value) return "出库单详情";
return form.id ? "编辑出库单" : "新建出库单";
});
const totalQuantity = computed(() =>
items.value.reduce((sum, item) => sum + (Number(item.quantity) || 0), 0)
);
function statusText(status) {
const map = {
DRAFT: "草稿",
AUDITING: "待审核",
COMPLETED: "已完成",
REJECTED: "已驳回",
CANCELLED: "已作废",
};
return map[status] || status || "-";
}
function statusTag(status) {
const map = {
DRAFT: "info",
AUDITING: "warning",
COMPLETED: "success",
REJECTED: "danger",
CANCELLED: "info",
};
return map[status] || "";
}
onMounted(() => {
const isReadonly = route.query.readonly === "1";
const id = route.query.id;
readonly.value = isReadonly;
loadReasons();
if (id) {
form.id = id;
loadDetail(id);
}
});
function loadReasons() {
getStockReasonList({ category: "OUTBOUND", pageNumber: 1, pageSize: 100 })
.then((res) => {
if (res.success) {
reasonOptions.value = res.result?.records || res.result || [];
}
})
.catch(() => {});
}
function handleReasonChange(val) {
const found = reasonOptions.value.find((item) => item.id === val);
form.reasonName = found ? found.reason : "";
}
function loadDetail(id) {
getStockOutboundDetail(id).then((res) => {
if (res.success && res.result) {
const vo = res.result;
const outbound = vo.outbound || vo;
detail.value = outbound;
form.id = outbound.id;
form.reasonId = outbound.reasonId;
form.reasonName = outbound.reasonName;
form.remark = outbound.remark;
items.value = (vo.items || []).map((it) => ({
goodsId: it.goodsId,
skuId: it.skuId,
goodsName: it.goodsName,
specs: it.specs,
quantity: it.quantity || 1,
unit: it.unit,
remark: it.remark,
}));
}
});
}
function openSkuPicker() {
if (liliDialogRef.value) {
liliDialogRef.value.goodsData = items.value.map((item) => ({
id: item.skuId,
goodsId: item.goodsId,
goodsName: item.goodsName,
}));
liliDialogRef.value.open("goods");
}
}
function onGoodsSelected(selected) {
const existingMap = new Map(items.value.map((item) => [item.skuId, item]));
items.value = selected.map((sku) => {
const existed = existingMap.get(sku.id);
if (existed) return existed;
return {
goodsId: sku.goodsId,
skuId: sku.id,
goodsName: sku.goodsName,
specs: sku.specs || sku.simpleSpecs || "",
quantity: 1,
unit: sku.goodsUnit || "件",
remark: "",
};
});
}
function removeItem(index) {
items.value.splice(index, 1);
}
async function handleSave(submit = false) {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (!valid) return;
if (!items.value.length) {
ElMessage.warning("请添加至少一种出库商品");
return;
}
for (const item of items.value) {
if (!item.quantity || item.quantity <= 0) {
ElMessage.warning(`商品【${item.goodsName}】的出库数量必须大于 0`);
return;
}
}
submitLoading.value = true;
try {
const payload = {
reasonId: form.reasonId,
reasonName: form.reasonName,
remark: form.remark,
items: items.value.map((it) => ({
goodsId: it.goodsId,
skuId: it.skuId,
goodsName: it.goodsName,
specs: it.specs,
quantity: it.quantity,
unit: it.unit,
remark: it.remark,
})),
};
let id = form.id;
if (id) {
await updateStockOutbound(id, payload);
} else {
const res = await createStockOutbound(payload);
id = res.result?.id || res.result?.outbound?.id || res.result;
}
if (submit && id) {
await submitStockOutbound(id);
ElMessage.success("出库单保存并提交成功");
} else {
ElMessage.success("出库单保存成功");
}
goBack();
} catch (e) {
console.error(e);
} finally {
submitLoading.value = false;
}
});
}
function goBack() {
router.push({ name: "stockOutboundList" });
}
</script>
<style scoped>
.stock-outbound-operation {
padding: 10px;
}
.card-header {
min-height: 32px;
}
.summary {
font-size: 14px;
}
.text-danger {
color: #f56c6c;
}
.text-primary {
color: #409eff;
}
</style>