feat: 补充平台端库存管理模块

补齐平台端库存余额、流水、初始化和手工调整能力,并将快递发货接入库存扣减闭环,方便运营侧统一查账与审计。

Made-with: Cursor
This commit is contained in:
AriadenCaseblg
2026-04-19 23:43:46 +08:00
parent b097837aa3
commit 005bd968df
32 changed files with 61113 additions and 59368 deletions

View File

@@ -0,0 +1,33 @@
import request from '@/utils/request'
export function inventoryListApi(params) {
return request({
url: '/admin/platform/inventory/list',
method: 'get',
params
})
}
export function inventoryRecordListApi(params) {
return request({
url: '/admin/platform/inventory/record/list',
method: 'get',
params
})
}
export function inventoryAdjustApi(data) {
return request({
url: '/admin/platform/inventory/adjust',
method: 'post',
data
})
}
export function inventoryInitApi(data) {
return request({
url: '/admin/platform/inventory/init',
method: 'post',
data
})
}

View File

@@ -63,6 +63,20 @@ export function expressList(data) {
});
}
// hooks/use-order 仍在使用 expressPageApi这里保持兼容导出
export function expressPageApi(data) {
return expressList(data);
}
// 创建物流公司弹窗会读取全部物流公司,这里补齐平台端兼容导出
export function expressAllApi(params) {
return request({
url: 'admin/express/all',
method: 'get',
params,
});
}
// 同步物流公司
export function expressSyncApi() {
return request({

View File

@@ -186,7 +186,7 @@ export function merchantOrderTimeApi(params) {
*/
export function merchantSheetInfoApi() {
return request({
url: `/admin/store/order/sheet/info`,
url: `/admin/merchant/elect/info`,
method: 'get',
});
}

View File

@@ -1,15 +1,22 @@
import { expressAllApi, expressPageApi } from '@/api/logistics';
function isEnabledExpress(item) {
if (typeof item?.isOpen === 'boolean') return item.isOpen;
if (typeof item?.isShow === 'boolean') return item.isShow;
if (typeof item?.status === 'boolean') return item.status;
return true;
}
/**
* 配置的物流公司
* @param param
* @returns {Promise<*>}
*/
// export async function useLogistics(param) {
// const res = await expressPageApi(param);
// const express = res.list.filter((item) => item.isOpen);
// return express;
// }
export async function useLogistics(param) {
const res = await expressPageApi(param);
const express = (res.list || []).filter((item) => isEnabledExpress(item));
return express;
}
/**
* 全部物流公司

View File

@@ -8,7 +8,7 @@
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
import Layout from '@/layout';
import Layout from '@/layout'
const productRouter = {
path: '/product',
@@ -17,52 +17,64 @@ const productRouter = {
name: 'Product',
meta: {
title: '商品',
icon: 'clipboard',
icon: 'clipboard'
},
children: [
{
path: 'list',
component: () => import('@/views/product/index'),
name: 'ProductIndex',
meta: { title: '商品列表', icon: '' },
meta: { title: '商品列表', icon: '' }
},
{
path: 'category',
component: () => import('@/views/product/category/index'),
name: 'ProductCategory',
meta: { title: '商品分类', icon: '' },
meta: { title: '商品分类', icon: '' }
},
{
path: 'comment',
component: () => import('@/views/product/comment/index'),
name: 'ProductComment',
meta: { title: '商品评论', icon: '' },
meta: { title: '商品评论', icon: '' }
},
{
path: 'brand',
component: () => import('@/views/product/brand/index'),
name: 'ProductBrand',
meta: { title: '品牌管理', icon: '' },
meta: { title: '品牌管理', icon: '' }
},
{
path: 'guarantee',
component: () => import('@/views/product/guarantee/index'),
name: 'ProductGuarantee',
meta: { title: '保障服务', icon: '' },
meta: { title: '保障服务', icon: '' }
},
{
path: 'tag',
component: () => import('@/views/product/tag/index'),
name: 'ProductTag',
meta: { title: '商品标签', icon: '' },
meta: { title: '商品标签', icon: '' }
},
{
path: 'tag/creatTag/:id?',
component: () => import('@/views/product/tag/creatTag'),
name: 'CreatTag',
meta: { title: '添加商品标签', icon: '', noCache: true, activeMenu: `/product/tag` },
meta: { title: '添加商品标签', icon: '', noCache: true, activeMenu: `/product/tag` }
},
],
};
{
path: 'inventory',
component: () => import('@/views/inventory/index'),
name: 'ProductInventory',
meta: { title: '库存管理', icon: '' }
},
{
path: 'inventory/record',
component: () => import('@/views/inventory/record'),
name: 'ProductInventoryRecord',
meta: { title: '库存流水', icon: '', activeMenu: `/product/inventory` }
}
]
}
export default productRouter;
export default productRouter

View File

@@ -0,0 +1,115 @@
<template>
<el-dialog
:visible.sync="dialogVisible"
title="库存调整"
width="520px"
destroy-on-close
:close-on-click-modal="false"
@close="handleClose"
>
<el-form ref="form" :model="form" :rules="rules" label-width="95px" size="small" @submit.native.prevent>
<el-form-item label="商品名称">
<div>{{ currentRow.productName || '--' }}</div>
</el-form-item>
<el-form-item label="规格">
<div>{{ currentRow.sku || '默认规格' }}</div>
</el-form-item>
<el-form-item label="当前库存">
<div>{{ currentRow.stock || 0 }}</div>
</el-form-item>
<el-form-item label="调整类型" prop="sourceType">
<el-radio-group v-model="form.sourceType">
<el-radio label="manual_in">手工入库</el-radio>
<el-radio label="manual_out">手工出库</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="调整数量" prop="number">
<el-input-number v-model="form.number" :min="1" :max="999999" />
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model.trim="form.remark" type="textarea" :rows="3" maxlength="255" show-word-limit />
</el-form-item>
</el-form>
<span slot="footer">
<el-button size="small" @click="handleClose">取消</el-button>
<el-button size="small" type="primary" @click="handleSubmit">确定</el-button>
</span>
</el-dialog>
</template>
<script>
import { inventoryAdjustApi } from '@/api/inventory'
export default {
name: 'InventoryAdjustDialog',
props: {
visible: {
type: Boolean,
default: false
},
row: {
type: Object,
default: () => ({})
}
},
data() {
return {
dialogVisible: this.visible,
currentRow: this.row,
form: {
productId: null,
attrValueId: null,
sourceType: 'manual_in',
number: 1,
remark: ''
},
rules: {
sourceType: [{ required: true, message: '请选择调整类型', trigger: 'change' }],
number: [{ required: true, message: '请输入调整数量', trigger: 'blur' }]
}
}
},
watch: {
visible(val) {
this.dialogVisible = val
if (val) {
this.resetForm()
}
},
row: {
handler(val) {
this.currentRow = val || {}
},
deep: true
}
},
methods: {
resetForm() {
this.form = {
productId: this.currentRow.productId || null,
attrValueId: this.currentRow.attrValueId || null,
sourceType: 'manual_in',
number: 1,
remark: ''
}
this.$nextTick(() => {
this.$refs.form && this.$refs.form.clearValidate()
})
},
handleClose() {
this.$emit('update:visible', false)
this.$emit('close')
},
handleSubmit() {
this.$refs.form.validate((valid) => {
if (!valid) return
inventoryAdjustApi(this.form).then(() => {
this.$message.success('库存调整成功')
this.$emit('success')
this.handleClose()
})
})
}
}
}
</script>

View File

@@ -0,0 +1,182 @@
<template>
<div class="divBox">
<el-card shadow="never" :bordered="false" class="ivu-mt" :body-style="{ padding: 0 }">
<div class="padding-add">
<el-form inline size="small" @submit.native.prevent>
<el-form-item label="商户名称:">
<merchant-name :mer-id-checked="tableFrom.merId" @getMerId="getMerId" />
</el-form-item>
<el-form-item label="商品搜索:">
<el-input
v-model.trim="tableFrom.keywords"
placeholder="请输入商品名称或SKU"
class="selWidth"
clearable
@keyup.enter.native="handleSearchList"
/>
</el-form-item>
<el-form-item label="库存状态:">
<el-select v-model="tableFrom.alertOnly" clearable class="selWidth" @change="handleSearchList">
<el-option :value="true" label="仅预警库存" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" size="small" @click="handleSearchList">查询</el-button>
<el-button size="small" @click="handleReset">重置</el-button>
<el-button
v-hasPermi="['platform:inventory:init']"
size="small"
type="primary"
plain
@click="handleInit"
>库存初始化</el-button>
<el-button size="small" plain @click="$router.push('/product/inventory/record')">查看流水</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
<el-card class="box-card mt14" :body-style="{ padding: '20px' }" shadow="never" :bordered="false">
<el-table v-loading="loading" :data="tableData.list || tableData.data || []" size="small" class="mt10">
<el-table-column prop="merchantName" label="商户名称" min-width="180" />
<el-table-column label="商品信息" min-width="260">
<template slot-scope="scope">
<div class="acea-row row-middle">
<el-image style="width: 36px; height: 36px" :src="scope.row.image" :preview-src-list="[scope.row.image]" />
<div class="ml10">
<div>{{ scope.row.productName || '--' }}</div>
<div class="font12 color-909399">SKU{{ scope.row.sku || '默认规格' }}</div>
</div>
</div>
</template>
</el-table-column>
<el-table-column label="当前库存" min-width="140">
<template slot-scope="scope">
<span>{{ scope.row.stock }}</span>
<el-tag
v-if="Number(scope.row.stock || 0) <= Number(scope.row.alertStock || 0)"
type="danger"
size="mini"
class="ml8"
>预警</el-tag>
</template>
</el-table-column>
<el-table-column prop="alertStock" label="预警库存" min-width="100" />
<el-table-column prop="lastOperateTime" label="最近变更时间" min-width="180" />
<el-table-column label="操作" width="180" fixed="right">
<template slot-scope="scope">
<a v-hasPermi="['platform:inventory:adjust']" @click="openAdjust(scope.row)">库存调整</a>
<el-divider direction="vertical" />
<a @click="goRecord(scope.row)">查看流水</a>
</template>
</el-table-column>
</el-table>
<div class="block">
<el-pagination
background
:page-sizes="[20, 40, 60, 80]"
:page-size="tableFrom.limit"
:current-page="tableFrom.page"
layout="total, sizes, prev, pager, next, jumper"
:total="tableData.total || 0"
@size-change="handleSizeChange"
@current-change="pageChange"
/>
</div>
</el-card>
<adjust-dialog :visible.sync="dialogVisible" :row="currentRow" @success="getList" />
</div>
</template>
<script>
import merchantName from '@/components/merchantName/index.vue'
import AdjustDialog from './components/adjustDialog.vue'
import { inventoryInitApi, inventoryListApi } from '@/api/inventory'
export default {
name: 'InventoryIndex',
components: { merchantName, AdjustDialog },
data() {
return {
loading: false,
dialogVisible: false,
currentRow: {},
tableFrom: {
page: 1,
limit: 20,
merId: null,
keywords: '',
alertOnly: undefined
},
tableData: {
list: [],
total: 0
}
}
},
created() {
this.getList()
},
methods: {
getMerId(merId) {
this.tableFrom.merId = merId
this.handleSearchList()
},
getList() {
this.loading = true
inventoryListApi(this.tableFrom)
.then((res) => {
this.tableData = res
})
.finally(() => {
this.loading = false
})
},
handleSearchList() {
this.tableFrom.page = 1
this.getList()
},
handleReset() {
this.tableFrom = {
page: 1,
limit: 20,
merId: null,
keywords: '',
alertOnly: undefined
}
this.getList()
},
pageChange(page) {
this.tableFrom.page = page
this.getList()
},
handleSizeChange(limit) {
this.tableFrom.limit = limit
this.getList()
},
openAdjust(row) {
this.currentRow = { ...row }
this.dialogVisible = true
},
goRecord(row) {
this.$router.push({
path: '/product/inventory/record',
query: {
merId: row.merId,
productId: row.productId,
keywords: row.sku || row.productName || ''
}
})
},
handleInit() {
this.$modalSure('确认按当前商品库存重建库存余额吗?').then(() => {
inventoryInitApi({ merId: this.tableFrom.merId || null, rebuild: true }).then(() => {
this.$message.success('库存初始化完成')
this.getList()
})
})
}
}
}
</script>

View File

@@ -0,0 +1,155 @@
<template>
<div class="divBox">
<el-card shadow="never" :bordered="false" class="ivu-mt" :body-style="{ padding: 0 }">
<div class="padding-add">
<el-form inline size="small" @submit.native.prevent>
<el-form-item label="商户名称:">
<merchant-name :mer-id-checked="tableFrom.merId" @getMerId="getMerId" />
</el-form-item>
<el-form-item label="关键词:">
<el-input
v-model.trim="tableFrom.keywords"
placeholder="商品名称/SKU/来源单号"
class="selWidth"
clearable
@keyup.enter.native="handleSearchList"
/>
</el-form-item>
<el-form-item label="来源类型:">
<el-select v-model="tableFrom.sourceType" clearable class="selWidth" @change="handleSearchList">
<el-option label="手工入库" value="manual_in" />
<el-option label="手工出库" value="manual_out" />
<el-option label="订单发货出库" value="order_delivery" />
<el-option label="库存初始化" value="init_sync" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" size="small" @click="handleSearchList">查询</el-button>
<el-button size="small" @click="handleReset">重置</el-button>
<el-button size="small" plain @click="$router.push('/product/inventory')">返回库存</el-button>
</el-form-item>
</el-form>
</div>
</el-card>
<el-card class="box-card mt14" :body-style="{ padding: '20px' }" shadow="never" :bordered="false">
<el-table v-loading="loading" :data="tableData.list || tableData.data || []" size="small">
<el-table-column prop="merchantName" label="商户名称" min-width="160" />
<el-table-column label="商品信息" min-width="220">
<template slot-scope="scope">
<div>{{ scope.row.productName || '--' }}</div>
<div class="font12 color-909399">SKU{{ scope.row.sku || '默认规格' }}</div>
</template>
</el-table-column>
<el-table-column label="方向" min-width="90">
<template slot-scope="scope">
<span :class="scope.row.pm === 1 ? 'color-67C23A' : 'color-E93323'">
{{ scope.row.pm === 1 ? '入库' : '出库' }}
</span>
</template>
</el-table-column>
<el-table-column prop="number" label="数量" min-width="90" />
<el-table-column label="库存变化" min-width="140">
<template slot-scope="scope">{{ scope.row.beforeStock }} -> {{ scope.row.afterStock }}</template>
</el-table-column>
<el-table-column label="来源类型" min-width="130">
<template slot-scope="scope">{{ formatSourceType(scope.row.sourceType) }}</template>
</el-table-column>
<el-table-column prop="sourceNo" label="来源单号" min-width="180" />
<el-table-column prop="operateAdminName" label="操作人" min-width="120" />
<el-table-column prop="remark" label="备注" min-width="180" />
<el-table-column prop="createTime" label="创建时间" min-width="170" />
</el-table>
<div class="block">
<el-pagination
background
:page-sizes="[20, 40, 60, 80]"
:page-size="tableFrom.limit"
:current-page="tableFrom.page"
layout="total, sizes, prev, pager, next, jumper"
:total="tableData.total || 0"
@size-change="handleSizeChange"
@current-change="pageChange"
/>
</div>
</el-card>
</div>
</template>
<script>
import merchantName from '@/components/merchantName/index.vue'
import { inventoryRecordListApi } from '@/api/inventory'
export default {
name: 'InventoryRecord',
components: { merchantName },
data() {
return {
loading: false,
tableFrom: {
page: 1,
limit: 20,
merId: this.$route.query.merId ? Number(this.$route.query.merId) : null,
productId: this.$route.query.productId ? Number(this.$route.query.productId) : null,
keywords: this.$route.query.keywords || '',
sourceType: ''
},
tableData: {
list: [],
total: 0
}
}
},
created() {
this.getList()
},
methods: {
formatSourceType(sourceType) {
const typeMap = {
manual_in: '手工入库',
manual_out: '手工出库',
order_delivery: '订单发货出库',
init_sync: '库存初始化'
}
return typeMap[sourceType] || sourceType || '--'
},
getMerId(merId) {
this.tableFrom.merId = merId
this.handleSearchList()
},
getList() {
this.loading = true
inventoryRecordListApi(this.tableFrom)
.then((res) => {
this.tableData = res
})
.finally(() => {
this.loading = false
})
},
handleSearchList() {
this.tableFrom.page = 1
this.getList()
},
handleReset() {
this.tableFrom = {
page: 1,
limit: 20,
merId: null,
productId: null,
keywords: '',
sourceType: ''
}
this.getList()
},
pageChange(page) {
this.tableFrom.page = page
this.getList()
},
handleSizeChange(limit) {
this.tableFrom.limit = limit
this.getList()
}
}
}
</script>

View File

@@ -122,9 +122,10 @@ import { defaultData } from '@/views/systemSetting/deliveryPersonnel/default';
import { personnelListApi } from '@/api/deliveryPersonnel';
import CreatPersonnel from '@/views/systemSetting/deliveryPersonnel/creatPersonnel';
import { checkPermi } from '@/utils/permission';
import { merchantElectrSheetInfo } from '@/api/systemSetting';
import { merchantSheetInfoApi as merchantElectrSheetInfo } from '@/api/merchantOrder';
import Cookies from 'js-cookie';
import { exportTempApi } from '@/api/logistics';
import { isPlatform } from '@/utils/settingMer';
export default {
name: 'sendFrom',
components: { CreatExpress, CreatPersonnel },
@@ -143,6 +144,7 @@ export default {
exportTempList: [],
merElectPrint: Cookies.get('merElectPrint'), // 商家小票打印开关状态
currentItemCode: '',
isPlatform,
};
},
props: {
@@ -157,7 +159,9 @@ export default {
},
mounted() {
this.getList();
this.getPersonnelList();
if (!this.isPlatform && this.checkPermi(['merchant:delivery:personnel:page'])) {
this.getPersonnelList();
}
//if (checkPermi(['admin:pass:shipment:express']))
this.getShipmentExpress();
},
@@ -174,15 +178,20 @@ export default {
},
// 选配送员确定回调
handlerSuccessSubmit() {
this.getPersonnelList();
if (!this.isPlatform) this.getPersonnelList();
this.dialogVisible = false;
},
// 配送员列表
async getPersonnelList() {
const data = await personnelListApi(this.tableFrom);
this.personnelList = data.list;
if (!this.isShowBtn)
try {
const data = await personnelListApi(this.tableFrom);
this.personnelList = data.list || [];
} catch (e) {
this.personnelList = [];
}
if (!this.isShowBtn) {
this.selectedValue = this.personnelList.filter((item) => item.personnelPhone === this.formItem.carrierPhone)[0];
}
},
// 添加
handleCreatPersonnel(row) {
@@ -202,17 +211,27 @@ export default {
limit: 50,
openStatus: true,
};
if (typeof useLogistics !== 'function') {
this.express = [];
return;
}
this.express = await useLogistics(params);
this.express.map((item) => {
if (item.isDefault && !this.formItem.id) this.formItem.expressCode = item.code;
});
},
getShipmentExpress() {
if (typeof merchantElectrSheetInfo !== 'function') {
this.shipmentExpress = {};
return;
}
merchantElectrSheetInfo().then((data) => {
this.shipmentExpress = data;
this.formItem.toName = data.senderUsername;
this.formItem.toTel = data.senderPhone;
this.formItem.toAddr = data.senderAddr;
this.shipmentExpress = data || {};
this.formItem.toName = data?.senderUsername || '';
this.formItem.toTel = data?.senderPhone || '';
this.formItem.toAddr = data?.senderAddr || '';
}).catch(() => {
this.shipmentExpress = {};
});
},
changeSendTypeRadio(expressRecordType) {

View File

@@ -9,10 +9,10 @@
>
<el-form v-if="modals" ref="formItem" :model="formItem" label-width="95px" @submit.native.prevent :rules="rules">
<el-form-item v-show="secondType !== OrderSecondTypeEnum.Fictitious" label="配送方式:" prop="deliveryType">
<el-radio-group v-model="formItem.deliveryType" @change="changeRadio(formItem.deliveryType)" v-removeAriaHidden>
<el-radio-group v-model="formItem.deliveryType" @change="changeRadio(formItem.deliveryType)">
<el-radio label="express">快递配送</el-radio>
<el-radio label="noNeed">无需发货</el-radio>
<el-radio label="merchant">商家送货</el-radio>
<el-radio label="merchant" v-if="!isPlatform && checkPermi(['merchant:delivery:personnel:page'])">商家送货</el-radio>
</el-radio-group>
</el-form-item>
<SendFrom :formItem="formItem" :isShowBtn="true"></SendFrom>
@@ -109,6 +109,7 @@ import SendFrom from './components/sendFrom';
import { useLogistics } from '@/hooks/use-order';
import { postRules } from '@/views/merchantOrder/default';
import { OrderSecondTypeEnum } from '@/enums/productEnums';
import { isPlatform } from '@/utils/settingMer';
const defaultObj = {
deliveryType: 'express',
isSplit: false,
@@ -169,6 +170,7 @@ export default {
},
data() {
return {
isPlatform,
OrderSecondTypeEnum: OrderSecondTypeEnum,
productList: [],
formItem: { ...defaultObj },
@@ -222,7 +224,15 @@ export default {
limit: 50,
openStatus: true,
};
this.express = await useLogistics(params);
if (typeof useLogistics !== 'function') {
this.express = [];
return;
}
try {
this.express = await useLogistics(params);
} catch (e) {
this.express = [];
}
this.express.map((item) => {
if (item.isDefault) this.formItem.expressCode = item.code;
});