InventoryManage.vue 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. <template>
  2. <div class="inventory-manage admin-page">
  3. <el-card>
  4. <div slot="header" class="admin-page-header">
  5. <span class="admin-page-title">库存管理</span>
  6. <el-button type="success" size="mini" icon="el-icon-plus" @click="$router.push('/inventory/inbound-create')">创建入库单</el-button>
  7. </div>
  8. <el-tabs v-model="activeTab">
  9. <!-- 库存流水 -->
  10. <el-tab-pane label="库存流水" name="transactions">
  11. <div class="filter-bar" style="margin-bottom:12px;">
  12. <el-input v-model="txFilters.productName" placeholder="商品名称" clearable style="width:160px;margin-right:8px;" />
  13. <el-select v-model="txFilters.type" placeholder="类型" clearable style="width:120px;margin-right:8px;">
  14. <el-option label="全部" value="" />
  15. <el-option label="入库" value="inbound" />
  16. <el-option label="出库" value="outbound" />
  17. <el-option label="调账" value="adjustment" />
  18. <el-option label="盘点" value="counting" />
  19. <el-option label="退款入库" value="refund" />
  20. </el-select>
  21. <el-date-picker v-model="txFilters.dateRange" type="daterange" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" value-format="yyyy-MM-dd" style="width:260px;margin-right:8px;" />
  22. <el-button type="primary" size="mini" @click="loadTransactions">查询</el-button>
  23. </div>
  24. <el-table :data="txList" v-loading="txLoading" border stripe max-height="500">
  25. <el-table-column prop="id" label="ID" width="60" />
  26. <el-table-column label="商品" min-width="150">
  27. <template slot-scope="{ row }">{{ row.productName || 'ID:' + row.productId }}</template>
  28. </el-table-column>
  29. <el-table-column label="类型" width="80">
  30. <template slot-scope="{ row }">
  31. <el-tag :type="row.type === 'inbound' || row.type === 'refund' ? 'success' : row.type === 'outbound' ? 'danger' : 'warning'" size="mini">
  32. {{ {'inbound':'入库','outbound':'出库','adjustment':'调账','counting':'盘点','refund':'退款入库'}[row.type] || row.type }}
  33. </el-tag>
  34. </template>
  35. </el-table-column>
  36. <el-table-column label="数量" width="80">
  37. <template slot-scope="{ row }">
  38. <span :style="{ color: row.direction === 1 ? '#67c23a' : '#f56c6c' }">{{ row.direction === 1 ? '+' : '-' }}{{ row.quantity }}</span>
  39. </template>
  40. </el-table-column>
  41. <el-table-column prop="beforeStock" label="变动前" width="70" />
  42. <el-table-column prop="afterStock" label="变动后" width="70" />
  43. <el-table-column prop="remark" label="备注" min-width="150" show-overflow-tooltip />
  44. <el-table-column prop="createdAt" label="时间" width="160">
  45. <template slot-scope="{ row }">{{ formatTime ? formatTime(row.createdAt) : row.createdAt }}</template>
  46. <template slot="header">
  47. <span class="sort-header" @click.stop="onSortClick('createdAt', $event)">时间<span v-if="sortIndex('createdAt') !== null" class="sort-index" :class="sortClass('createdAt') === 'sort-asc' ? 'sort-asc-badge' : 'sort-desc-badge'">{{ sortIndex('createdAt') }}</span>{{ sortIcon('createdAt') }}</span>
  48. </template>
  49. </el-table-column>
  50. </el-table>
  51. <el-pagination @size-change="txPageSize=$event;loadTransactions()" @current-change="txPage=$event;loadTransactions()" :current-page="txPage" :page-sizes="[10,20,50]" :page-size="txPageSize" :total="txTotal" layout="total, sizes, prev, pager, next, jumper" class="pagination-wrap" />
  52. </el-tab-pane>
  53. <!-- 入库单 -->
  54. <el-tab-pane label="入库单" name="inbound">
  55. <el-table :data="inboundList" v-loading="inboundLoading" border stripe max-height="500">
  56. <el-table-column prop="orderNo" label="入库单号" width="180" />
  57. <el-table-column prop="supplierName" label="供应商" width="120" />
  58. <el-table-column label="入库日期" width="100">
  59. <template slot-scope="{ row }">{{ row.inboundDate || '-' }}</template>
  60. </el-table-column>
  61. <el-table-column label="总金额" width="100">
  62. <template slot-scope="{ row }">{{ row.totalAmount ? '¥' + (row.totalAmount / 100).toFixed(2) : '-' }}</template>
  63. </el-table-column>
  64. <el-table-column prop="totalItems" label="商品项数" width="80" />
  65. <el-table-column label="状态" width="90">
  66. <template slot-scope="{ row }">
  67. <el-tag :type="row.status === 'completed' ? 'success' : row.status === 'cancelled' ? 'info' : 'warning'" size="mini">
  68. {{ {'pending':'待确认','completed':'已完成','cancelled':'已取消'}[row.status] || row.status }}
  69. </el-tag>
  70. </template>
  71. </el-table-column>
  72. <el-table-column prop="createdAt" label="创建时间" width="160" />
  73. <el-table-column label="操作" width="200" fixed="right">
  74. <template slot-scope="{ row }">
  75. <el-button size="mini" @click="showInboundDetail(row)">详情</el-button>
  76. <el-button v-if="row.status === 'pending'" size="mini" type="success" @click="handleConfirmInbound(row)">确认入库</el-button>
  77. <el-button v-if="row.status === 'pending'" size="mini" type="danger" @click="handleCancelInbound(row)">取消</el-button>
  78. </template>
  79. </el-table-column>
  80. </el-table>
  81. <el-pagination @size-change="inboundPageSize=$event;loadInboundOrders()" @current-change="inboundPage=$event;loadInboundOrders()" :current-page="inboundPage" :page-sizes="[10,20,50]" :page-size="inboundPageSize" :total="inboundTotal" layout="total, sizes, prev, pager, next, jumper" class="pagination-wrap" />
  82. </el-tab-pane>
  83. <!-- 库存预警 -->
  84. <el-tab-pane label="库存预警" name="alerts">
  85. <el-button type="primary" size="mini" @click="loadAlerts" style="margin-bottom:12px;">刷新</el-button>
  86. <el-table :data="alertList" v-loading="alertLoading" border stripe max-height="500">
  87. <el-table-column prop="productName" label="商品名称" min-width="200" />
  88. <el-table-column prop="specs" label="SKU规格" width="150">
  89. <template slot-scope="{ row }">{{ row.specs || '-' }}</template>
  90. </el-table-column>
  91. <el-table-column prop="currentStock" label="当前库存" width="80">
  92. <template slot-scope="{ row }"><span style="color:#f56c6c;font-weight:bold;">{{ row.currentStock }}</span></template>
  93. </el-table-column>
  94. <el-table-column prop="minStockAlert" label="预警下限" width="80" />
  95. </el-table>
  96. </el-tab-pane>
  97. <!-- 出库单 -->
  98. <el-tab-pane label="出库单" name="outbound">
  99. <el-button type="primary" size="mini" @click="showCreateOutbound = true" style="margin-bottom:12px;">新建出库单</el-button>
  100. <el-table :data="outboundList" v-loading="outboundLoading" border stripe max-height="500">
  101. <el-table-column prop="id" label="ID" width="60" />
  102. <el-table-column label="商品" min-width="150">
  103. <template slot-scope="{ row }">{{ row.productName || 'ID:' + row.productId }}</template>
  104. </el-table-column>
  105. <el-table-column label="数量" width="70">
  106. <template slot-scope="{ row }"><span style="color:#f56c6c;">-{{ row.quantity }}</span></template>
  107. </el-table-column>
  108. <el-table-column prop="remark" label="出库原因" min-width="200" show-overflow-tooltip />
  109. <el-table-column prop="createdAt" label="时间" width="160" />
  110. </el-table>
  111. <el-pagination @size-change="outboundPageSize=$event;loadOutboundList()" @current-change="outboundPage=$event;loadOutboundList()" :current-page="outboundPage" :page-sizes="[10,20,50]" :page-size="outboundPageSize" :total="outboundTotal" layout="total, sizes, prev, pager, next, jumper" class="pagination-wrap" />
  112. </el-tab-pane>
  113. <!-- 盘点记录 -->
  114. <el-tab-pane label="盘点记录" name="counting">
  115. <el-button type="primary" size="mini" @click="showCreateCounting = true" style="margin-bottom:12px;">新建盘点</el-button>
  116. <el-table :data="countingList" v-loading="countingLoading" border stripe max-height="500">
  117. <el-table-column prop="id" label="ID" width="60" />
  118. <el-table-column label="系统库存" width="80">
  119. <template slot-scope="{ row }">{{ row.expectedStock }}</template>
  120. </el-table-column>
  121. <el-table-column label="实际库存" width="80">
  122. <template slot-scope="{ row }">{{ row.actualStock }}</template>
  123. </el-table-column>
  124. <el-table-column label="差异" width="80">
  125. <template slot-scope="{ row }">
  126. <span :style="{ color: row.difference > 0 ? '#67c23a' : row.difference < 0 ? '#f56c6c' : '#999' }">{{ row.difference > 0 ? '+' : '' }}{{ row.difference }}</span>
  127. </template>
  128. </el-table-column>
  129. <el-table-column label="状态" width="80">
  130. <template slot-scope="{ row }">
  131. <el-tag :type="row.status === 'confirmed' ? 'success' : 'warning'" size="mini">{{ row.status === 'confirmed' ? '已确认' : '待确认' }}</el-tag>
  132. </template>
  133. </el-table-column>
  134. <el-table-column prop="remark" label="备注" min-width="150" />
  135. <el-table-column prop="createdAt" label="时间" width="160" />
  136. <el-table-column label="操作" width="100" fixed="right">
  137. <template slot-scope="{ row }">
  138. <el-button v-if="row.status === 'pending'" size="mini" type="success" @click="handleConfirmCounting(row)">确认</el-button>
  139. </template>
  140. </el-table-column>
  141. </el-table>
  142. <el-pagination @size-change="countingPageSize=$event;loadCountingRecords()" @current-change="countingPage=$event;loadCountingRecords()" :current-page="countingPage" :page-sizes="[10,20,50]" :page-size="countingPageSize" :total="countingTotal" layout="total, sizes, prev, pager, next, jumper" class="pagination-wrap" />
  143. </el-tab-pane>
  144. </el-tabs>
  145. </el-card>
  146. <!-- 入库单详情弹窗 -->
  147. <el-dialog title="入库单详情" :visible.sync="detailDialogVisible" width="700px">
  148. <el-descriptions :column="2" border v-if="inboundDetail">
  149. <el-descriptions-item label="入库单号">{{ inboundDetail.order.orderNo }}</el-descriptions-item>
  150. <el-descriptions-item label="供应商">{{ inboundDetail.order.supplierName || '-' }}</el-descriptions-item>
  151. <el-descriptions-item label="状态">{{ inboundDetail.order.status }}</el-descriptions-item>
  152. <el-descriptions-item label="备注">{{ inboundDetail.order.remark || '-' }}</el-descriptions-item>
  153. </el-descriptions>
  154. <el-table :data="inboundDetail.items" border stripe style="margin-top:12px;" v-if="inboundDetail">
  155. <el-table-column prop="productId" label="商品ID" width="70" />
  156. <el-table-column prop="skuId" label="SKU ID" width="70" />
  157. <el-table-column prop="quantity" label="数量" width="70" />
  158. </el-table>
  159. </el-dialog>
  160. <!-- 新建出库弹窗 -->
  161. <el-dialog title="新建出库单" :visible.sync="showCreateOutbound" width="500px">
  162. <el-form label-width="100px">
  163. <el-form-item label="商品">
  164. <el-select v-model="outboundForm.productId" filterable remote placeholder="搜索商品" :remote-method="searchOutboundProduct" style="width:300px;">
  165. <el-option v-for="p in outboundProductOptions" :key="p.id" :label="p.name + ' (库存:' + (p.stock || 0) + ')'" :value="p.id" />
  166. </el-select>
  167. </el-form-item>
  168. <el-form-item label="SKU" v-if="outboundSkuOptions.length">
  169. <el-select v-model="outboundForm.skuId" placeholder="选择规格" clearable style="width:200px;">
  170. <el-option v-for="s in outboundSkuOptions" :key="s.id" :label="s.specs || '默认' + ' (库存:' + (s.stock || 0) + ')'" :value="s.id" />
  171. </el-select>
  172. </el-form-item>
  173. <el-form-item label="数量">
  174. <el-input v-model.number="outboundForm.quantity" type="number" min="1" />
  175. </el-form-item>
  176. <el-form-item label="出库原因">
  177. <el-select v-model="outboundForm.reason" placeholder="选择原因" style="width:200px;">
  178. <el-option label="赠品" value="赠品" />
  179. <el-option label="损耗" value="损耗" />
  180. <el-option label="样品" value="样品" />
  181. <el-option label="其他" value="其他" />
  182. </el-select>
  183. </el-form-item>
  184. <el-form-item label="备注">
  185. <el-input v-model="outboundForm.remark" type="textarea" :rows="2" />
  186. </el-form-item>
  187. </el-form>
  188. <span slot="footer">
  189. <el-button @click="showCreateOutbound = false">取消</el-button>
  190. <el-button type="danger" @click="handleCreateOutbound">确认出库</el-button>
  191. </span>
  192. </el-dialog>
  193. <!-- 新建盘点弹窗 -->
  194. <el-dialog title="新建盘点" :visible.sync="showCreateCounting" width="450px">
  195. <el-form label-width="100px">
  196. <el-form-item label="商品ID">
  197. <el-input v-model="countingForm.productId" type="number" />
  198. </el-form-item>
  199. <el-form-item label="SKU ID">
  200. <el-input v-model="countingForm.skuId" type="number" placeholder="选填" />
  201. </el-form-item>
  202. <el-form-item label="实际库存">
  203. <el-input v-model="countingForm.actualStock" type="number" />
  204. </el-form-item>
  205. <el-form-item label="备注">
  206. <el-input v-model="countingForm.remark" type="textarea" :rows="2" />
  207. </el-form-item>
  208. </el-form>
  209. <span slot="footer">
  210. <el-button @click="showCreateCounting = false">取消</el-button>
  211. <el-button type="primary" @click="handleCreateCounting">提交</el-button>
  212. </span>
  213. </el-dialog>
  214. </div>
  215. </template>
  216. <script>
  217. import SortMixin from '@/mixins/SortMixin'
  218. import { getInventoryTransactions, getInboundOrderList, getInboundOrderDetail, confirmInbound, cancelInbound, getStockAlerts, getCountingList, createCountingRecord, confirmCounting, createManualOutbound, getManualOutboundList, getProductList, getProductSkuList } from '@/api/admin'
  219. export default {
  220. mixins: [SortMixin],
  221. name: 'InventoryManage',
  222. mixins: [SortMixin],
  223. data() {
  224. return {
  225. activeTab: 'transactions',
  226. txList: [], txLoading: false, txPage: 1, txPageSize: 20, txTotal: 0,
  227. txFilters: { productName: '', type: '', dateRange: null },
  228. inboundList: [], inboundLoading: false, inboundPage: 1, inboundPageSize: 20, inboundTotal: 0,
  229. inboundDetail: null, detailDialogVisible: false,
  230. alertList: [], alertLoading: false,
  231. countingList: [], countingLoading: false, countingPage: 1, countingPageSize: 20, countingTotal: 0,
  232. showCreateCounting: false, countingForm: { productId: '', skuId: '', actualStock: '', remark: '' },
  233. outboundList: [], outboundLoading: false, outboundPage: 1, outboundPageSize: 20, outboundTotal: 0,
  234. showCreateOutbound: false, outboundProductOptions: [], outboundSkuOptions: [],
  235. outboundForm: { productId: '', skuId: '', quantity: 1, reason: '', remark: '' },
  236. sortableColumns: { id: 'ID', createdAt: '时间' }
  237. }
  238. },
  239. mounted() {
  240. this.loadTransactions()
  241. this.loadInboundOrders()
  242. this.loadAlerts()
  243. this.loadCountingRecords()
  244. this.loadOutboundList()
  245. },
  246. methods: {
  247. loadList() { this.loadTransactions() },
  248. async loadTransactions() {
  249. this.txLoading = true
  250. try {
  251. const params = {
  252. page: this.txPage, size: this.txPageSize,
  253. type: this.txFilters.type || null,
  254. startDate: this.txFilters.dateRange ? this.txFilters.dateRange[0] : null,
  255. endDate: this.txFilters.dateRange ? this.txFilters.dateRange[1] : null
  256. }
  257. if (this.sortState.length > 0) params.sort = this.sortState
  258. const res = await getInventoryTransactions(params)
  259. this.txList = res.data.records || []
  260. this.txTotal = res.data.total || 0
  261. } catch (e) { this.$message.error('加载失败') }
  262. finally { this.txLoading = false }
  263. },
  264. async loadInboundOrders() {
  265. this.inboundLoading = true
  266. try {
  267. const res = await getInboundOrderList({ page: this.inboundPage, size: this.inboundPageSize })
  268. this.inboundList = res.data.records || []
  269. this.inboundTotal = res.data.total || 0
  270. } catch (e) { this.$message.error('加载失败') }
  271. finally { this.inboundLoading = false }
  272. },
  273. async showInboundDetail(row) {
  274. try {
  275. const res = await getInboundOrderDetail(row.id)
  276. this.inboundDetail = res.data
  277. this.detailDialogVisible = true
  278. } catch (e) { this.$message.error('加载失败') }
  279. },
  280. async handleConfirmInbound(row) {
  281. try {
  282. await this.$confirm('确认入库?库存将被增加,不可撤销。', '提示')
  283. await confirmInbound(row.id)
  284. this.$message.success('入库成功')
  285. this.loadInboundOrders()
  286. } catch (e) { if (e !== 'cancel') this.$message.error('操作失败') }
  287. },
  288. async handleCancelInbound(row) {
  289. try {
  290. await this.$confirm('确认取消该入库单?', '提示')
  291. await cancelInbound(row.id)
  292. this.$message.success('已取消')
  293. this.loadInboundOrders()
  294. } catch (e) { if (e !== 'cancel') this.$message.error('操作失败') }
  295. },
  296. async loadAlerts() {
  297. this.alertLoading = true
  298. try {
  299. const res = await getStockAlerts()
  300. this.alertList = res.data || []
  301. } catch (e) { this.$message.error('加载失败') }
  302. finally { this.alertLoading = false }
  303. },
  304. async loadCountingRecords() {
  305. this.countingLoading = true
  306. try {
  307. const res = await getCountingList({ page: this.countingPage, size: this.countingPageSize })
  308. this.countingList = res.data.records || []
  309. this.countingTotal = res.data.total || 0
  310. } catch (e) { this.$message.error('加载失败') }
  311. finally { this.countingLoading = false }
  312. },
  313. async handleCreateCounting() {
  314. if (!this.countingForm.productId || !this.countingForm.actualStock) {
  315. return this.$message.warning('请填写必填项')
  316. }
  317. try {
  318. await createCountingRecord({
  319. productId: parseInt(this.countingForm.productId),
  320. skuId: this.countingForm.skuId ? parseInt(this.countingForm.skuId) : null,
  321. actualStock: parseInt(this.countingForm.actualStock),
  322. remark: this.countingForm.remark
  323. })
  324. this.$message.success('盘点记录已创建')
  325. this.showCreateCounting = false
  326. this.countingForm = { productId: '', skuId: '', actualStock: '', remark: '' }
  327. this.loadCountingRecords()
  328. } catch (e) { this.$message.error('操作失败') }
  329. },
  330. async handleConfirmCounting(row) {
  331. try {
  332. await this.$confirm('确认盘点结果?差异将自动调账。', '提示')
  333. await confirmCounting(row.id)
  334. this.$message.success('盘点已确认')
  335. this.loadCountingRecords()
  336. } catch (e) { if (e !== 'cancel') this.$message.error('操作失败') }
  337. },
  338. async loadOutboundList() {
  339. this.outboundLoading = true
  340. try {
  341. const res = await getManualOutboundList({ page: this.outboundPage, size: this.outboundPageSize })
  342. this.outboundList = res.data.records || []
  343. this.outboundTotal = res.data.total || 0
  344. } catch (e) { this.$message.error('加载失败') }
  345. finally { this.outboundLoading = false }
  346. },
  347. async searchOutboundProduct(query) {
  348. if (!query || query.length < 1) return
  349. try {
  350. const res = await getProductList({ keyword: query, page: 1, size: 20 })
  351. this.outboundProductOptions = res.data.records || []
  352. } catch (e) { /* ignore */ }
  353. },
  354. async handleCreateOutbound() {
  355. if (!this.outboundForm.productId || !this.outboundForm.quantity) {
  356. return this.$message.warning('请填写必填项')
  357. }
  358. try {
  359. await this.$confirm('确认出库?库存将被扣减。', '提示')
  360. await createManualOutbound({
  361. productId: parseInt(this.outboundForm.productId),
  362. skuId: this.outboundForm.skuId ? parseInt(this.outboundForm.skuId) : null,
  363. quantity: parseInt(this.outboundForm.quantity),
  364. reason: this.outboundForm.reason || null,
  365. remark: this.outboundForm.remark || null
  366. })
  367. this.$message.success('出库成功')
  368. this.showCreateOutbound = false
  369. this.outboundForm = { productId: '', skuId: '', quantity: 1, reason: '', remark: '' }
  370. this.loadOutboundList()
  371. } catch (e) { if (e !== 'cancel') this.$message.error('操作失败') }
  372. }
  373. }
  374. }
  375. </script>
  376. <style scoped>
  377. .inventory-manage { padding: 20px; }
  378. .filter-bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
  379. .pagination-wrap { margin-top: 12px; text-align: right; }
  380. .sort-header {
  381. cursor: pointer;
  382. user-select: none;
  383. font-weight: 500;
  384. font-size: 12px;
  385. }
  386. .sort-header:hover {
  387. color: #409eff;
  388. }
  389. .sort-tag {
  390. margin-left: 8px;
  391. cursor: pointer;
  392. white-space: nowrap;
  393. }
  394. </style>