CreateDialog.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. <script setup lang="ts">
  2. import { ref, reactive, computed } from "vue";
  3. import { message } from "@/utils/message";
  4. import { createOrder, getReplenishmentSuggestions } from "@/api/replenishmentOrder";
  5. import { getDeviceList } from "@/api/device";
  6. import { getDeviceInventoryStats } from "@/api/inventory";
  7. import type { ReplenishmentOrderItem } from "../utils/types";
  8. const emit = defineEmits<{
  9. (e: "success"): void;
  10. }>();
  11. const visible = ref(false);
  12. const loading = ref(false);
  13. const suggestionsLoading = ref(false);
  14. // 设备列表
  15. const deviceList = ref<any[]>([]);
  16. const selectedDevices = ref<any[]>([]);
  17. const showLowStockOnly = ref(false);
  18. const lowStockDeviceIds = ref<Set<string>>(new Set());
  19. // 需要补货的设备(库存不足)
  20. const filteredDeviceList = computed(() => {
  21. if (!showLowStockOnly.value) return deviceList.value;
  22. return deviceList.value.filter((d: any) => lowStockDeviceIds.value.has(d.deviceId));
  23. });
  24. // 补货建议结果(按设备分组)。用 reactive 替代 ref,确保数组内嵌套对象的深层属性修改可靠响应
  25. const suggestions = reactive<any[]>([]);
  26. // 公共字段
  27. const commonForm = reactive({
  28. supplierName: "",
  29. warehouseName: "",
  30. expectedArrivalTime: ""
  31. });
  32. async function open() {
  33. resetState();
  34. visible.value = true;
  35. await loadDevices();
  36. }
  37. function resetState() {
  38. selectedDevices.value = [];
  39. suggestions.length = 0;
  40. suggestionsLoading.value = false;
  41. showLowStockOnly.value = false;
  42. commonForm.supplierName = "";
  43. commonForm.warehouseName = "";
  44. commonForm.expectedArrivalTime = "";
  45. }
  46. async function loadDevices() {
  47. try {
  48. const [deviceRes, statsRes] = await Promise.all([
  49. getDeviceList({ page: 1, pageSize: 1000 }),
  50. getDeviceInventoryStats({ page: 1, pageSize: 1000 })
  51. ]);
  52. deviceList.value = deviceRes.data?.list || [];
  53. // 从库存统计中提取需要补货的设备ID
  54. const stats = statsRes.data?.list || [];
  55. lowStockDeviceIds.value = new Set(
  56. stats
  57. .filter((s: any) => s.stock_status === "缺货" || s.stock_status === "低库存")
  58. .map((s: any) => s.device_id)
  59. );
  60. } catch (e) {
  61. console.error("加载设备列表失败:", e);
  62. }
  63. }
  64. function handleSelectionChange(selection: any[]) {
  65. selectedDevices.value = selection;
  66. }
  67. function resetToSelect() {
  68. suggestions.length = 0;
  69. selectedDevices.value = [];
  70. }
  71. async function fetchSuggestions() {
  72. if (selectedDevices.value.length === 0) {
  73. message("请先勾选设备", { type: "warning" });
  74. return;
  75. }
  76. suggestionsLoading.value = true;
  77. try {
  78. const ids = selectedDevices.value.map((d: any) => d.deviceId as string);
  79. const { data } = await getReplenishmentSuggestions(ids);
  80. const list: any[] = Array.isArray(data) ? data : [];
  81. if (list.length === 0) {
  82. message("所选设备当前均无需补货", { type: "info" });
  83. }
  84. suggestions.length = 0;
  85. suggestions.push(...list);
  86. } catch (e) {
  87. console.error("获取补货建议失败:", e);
  88. message("获取补货建议失败", { type: "error" });
  89. } finally {
  90. suggestionsLoading.value = false;
  91. }
  92. }
  93. async function handleSubmit() {
  94. const validSuggestions = suggestions.filter(
  95. s => s.items.some((i: any) => i.suggestedQuantity > 0)
  96. );
  97. if (validSuggestions.length === 0) {
  98. message("没有有效的补货项,请检查补货数量", { type: "warning" });
  99. return;
  100. }
  101. loading.value = true;
  102. try {
  103. const payloads = validSuggestions.map((s: any) => ({
  104. deviceId: s.deviceId,
  105. shopId: s.shopId || undefined,
  106. supplierName: commonForm.supplierName || undefined,
  107. warehouseName: commonForm.warehouseName || undefined,
  108. expectedArrivalTime: commonForm.expectedArrivalTime || undefined,
  109. remark: undefined,
  110. items: s.items
  111. .filter((i: any) => i.suggestedQuantity > 0)
  112. .map((i: any) => ({
  113. productId: i.productId,
  114. productCode: i.productCode,
  115. productName: i.productName,
  116. plannedQuantity: i.suggestedQuantity,
  117. unitPrice: i.unitPrice != null ? i.unitPrice : undefined,
  118. shelfNum: i.shelfNum,
  119. position: i.position
  120. }))
  121. }));
  122. const results = await Promise.allSettled(
  123. payloads.map((p: any) => createOrder(p))
  124. );
  125. const successCount = results.filter(r => r.status === "fulfilled").length;
  126. const failCount = results.filter(r => r.status === "rejected").length;
  127. if (failCount === 0) {
  128. message(`成功创建 ${successCount} 个补货单`, { type: "success" });
  129. } else {
  130. message(`创建完成:${successCount} 成功, ${failCount} 失败`, { type: "warning" });
  131. }
  132. visible.value = false;
  133. emit("success");
  134. } finally {
  135. loading.value = false;
  136. }
  137. }
  138. function deviceTotalQty(sug: any) {
  139. return sug.items.reduce((sum: number, i: any) => sum + (i.suggestedQuantity || 0), 0);
  140. }
  141. function deviceTotalAmt(sug: any) {
  142. return sug.items.reduce((sum: number, i: any) => sum + (i.suggestedQuantity || 0) * (i.unitPrice || 0), 0);
  143. }
  144. function itemSubtotal(item: any) {
  145. return ((item.suggestedQuantity || 0) * (item.unitPrice || 0)).toFixed(2);
  146. }
  147. defineExpose({ open });
  148. </script>
  149. <template>
  150. <el-dialog
  151. v-model="visible"
  152. title="新建补货单"
  153. width="85%"
  154. top="3vh"
  155. :close-on-click-modal="false"
  156. destroy-on-close
  157. >
  158. <div style="min-height: 500px; max-height: 78vh; overflow-y: auto;">
  159. <!-- 第一步:设备列表 -->
  160. <div style="margin-bottom:8px;font-size:14px;font-weight:600;color:#303133;">
  161. 第一步:勾选需要补货的设备
  162. <el-switch
  163. v-model="showLowStockOnly"
  164. active-text="仅显示需补货"
  165. inactive-text="全部设备"
  166. size="small"
  167. style="margin-left:16px;"
  168. />
  169. </div>
  170. <el-table
  171. ref="deviceTableRef"
  172. :data="filteredDeviceList"
  173. max-height="420"
  174. @selection-change="handleSelectionChange"
  175. >
  176. <el-table-column type="selection" width="55" />
  177. <el-table-column prop="deviceId" label="设备ID" min-width="140" />
  178. <el-table-column prop="name" label="设备名称" min-width="140" />
  179. <el-table-column prop="shopName" label="所属门店" min-width="140" />
  180. </el-table>
  181. <div style="margin-top:12px;display:flex;align-items:center;gap:12px;">
  182. <el-button
  183. type="primary"
  184. :loading="suggestionsLoading"
  185. :disabled="selectedDevices.length === 0"
  186. @click="fetchSuggestions"
  187. >
  188. 补货建议
  189. </el-button>
  190. <span style="color:#909399;font-size:13px;">
  191. 已勾选 {{ selectedDevices.length }} 台设备
  192. <template v-if="suggestions.length > 0">
  193. ,共 {{ suggestions.reduce((s, sug) => s + sug.items.length, 0) }} 项待补商品
  194. </template>
  195. </span>
  196. </div>
  197. <!-- 第二步:补货明细 -->
  198. <template v-if="suggestions.length > 0">
  199. <div style="margin-top:24px;margin-bottom:12px;font-size:14px;font-weight:600;color:#303133;">
  200. 第二步:确认补货明细,填写供应商等信息
  201. </div>
  202. <div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:16px;padding:12px;background:#fafafa;border-radius:6px;">
  203. <div style="display:flex;align-items:center;gap:6px;">
  204. <span style="font-size:13px;color:#606266;white-space:nowrap;">供应商</span>
  205. <el-input v-model="commonForm.supplierName" placeholder="选填" clearable size="small" style="width:150px;" />
  206. </div>
  207. <div style="display:flex;align-items:center;gap:6px;">
  208. <span style="font-size:13px;color:#606266;white-space:nowrap;">仓库</span>
  209. <el-input v-model="commonForm.warehouseName" placeholder="选填" clearable size="small" style="width:150px;" />
  210. </div>
  211. <div style="display:flex;align-items:center;gap:6px;">
  212. <span style="font-size:13px;color:#606266;white-space:nowrap;">预计到货</span>
  213. <el-date-picker
  214. v-model="commonForm.expectedArrivalTime"
  215. type="datetime"
  216. placeholder="选填"
  217. value-format="YYYY-MM-DD HH:mm:ss"
  218. size="small"
  219. style="width:190px;"
  220. />
  221. </div>
  222. </div>
  223. <!-- 按设备分组 -->
  224. <el-card
  225. v-for="sug in suggestions"
  226. :key="sug.deviceId"
  227. style="margin-bottom:12px;"
  228. shadow="hover"
  229. >
  230. <template #header>
  231. <div style="display:flex;justify-content:space-between;align-items:center;">
  232. <span style="font-size:14px;font-weight:600;">
  233. {{ sug.deviceId }}
  234. <template v-if="sug.deviceName">({{ sug.deviceName }})</template>
  235. <span style="margin:0 8px;color:#dcdfe6;">|</span>
  236. {{ sug.shopName || "未绑定门店" }}
  237. </span>
  238. <span style="color:#409eff;font-size:13px;">
  239. {{ deviceTotalQty(sug) }} 件 / ¥{{ deviceTotalAmt(sug).toFixed(2) }}
  240. </span>
  241. </div>
  242. </template>
  243. <table style="width:100%;border-collapse:collapse;">
  244. <thead>
  245. <tr style="background:#f5f7fa;">
  246. <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">图片</th>
  247. <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">商品编码</th>
  248. <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">商品名称</th>
  249. <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">标准库存</th>
  250. <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">当前库存</th>
  251. <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">补货数量</th>
  252. <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">单价</th>
  253. <th style="padding:8px 10px;border:1px solid #ebeef5;font-size:13px;font-weight:500;color:#606266;">小计</th>
  254. </tr>
  255. </thead>
  256. <tbody>
  257. <tr v-for="(item, idx) in sug.items" :key="idx">
  258. <td style="padding:4px;border:1px solid #ebeef5;text-align:center;width:60px;">
  259. <img
  260. v-if="item.productImage"
  261. :src="item.productImage"
  262. style="width:40px;height:40px;object-fit:cover;border-radius:4px;"
  263. @error="$event.target.style.display='none'"
  264. />
  265. <span v-else style="color:#ccc;font-size:20px;">-</span>
  266. </td>
  267. <td style="padding:6px 10px;border:1px solid #ebeef5;font-size:13px;">{{ item.productCode }}</td>
  268. <td style="padding:6px 10px;border:1px solid #ebeef5;font-size:13px;">{{ item.productName || "-" }}</td>
  269. <td style="padding:6px 10px;border:1px solid #ebeef5;text-align:center;font-size:13px;">{{ item.templateStock }}</td>
  270. <td style="padding:6px 10px;border:1px solid #ebeef5;text-align:center;font-size:13px;">
  271. <span :style="{ fontWeight:'500', color: item.currentStock <= 0 ? '#f56c6c' : '#67c23a' }">
  272. {{ item.currentStock }}
  273. </span>
  274. </td>
  275. <td style="padding:4px 6px;border:1px solid #ebeef5;">
  276. <el-input-number
  277. v-model="item.suggestedQuantity"
  278. :min="1"
  279. :max="9999"
  280. size="small"
  281. controls-position="right"
  282. style="width:100px;"
  283. />
  284. </td>
  285. <td style="padding:4px 6px;border:1px solid #ebeef5;">
  286. <el-input v-model="item.unitPrice" placeholder="单价" size="small" style="width:90px;" />
  287. </td>
  288. <td style="padding:6px 10px;border:1px solid #ebeef5;text-align:right;font-size:13px;font-weight:500;">
  289. ¥{{ itemSubtotal(item) }}
  290. </td>
  291. </tr>
  292. </tbody>
  293. </table>
  294. </el-card>
  295. <div style="margin-top:16px;padding:10px 16px;background:#ecf5ff;border-radius:6px;color:#409eff;font-size:13px;">
  296. 确认补货明细无误后,点击「确认创建」按钮即可创建补货单
  297. </div>
  298. </template>
  299. </div>
  300. <template #footer>
  301. <div style="display:flex;justify-content:flex-end;gap:12px;">
  302. <el-button @click="visible = false">取消</el-button>
  303. <el-button
  304. v-if="suggestions.length > 0"
  305. @click="resetToSelect"
  306. >
  307. 重新选择
  308. </el-button>
  309. <el-button
  310. v-if="suggestions.length > 0"
  311. type="primary"
  312. :loading="loading"
  313. @click="handleSubmit"
  314. >
  315. 确认创建
  316. </el-button>
  317. </div>
  318. </template>
  319. </el-dialog>
  320. </template>