Explorar o código

feat(product): 线上服务商品管理闭环 - 服务者/时段配置UI + nutritionist服务商类型

E2E Test Bot hai 1 mes
pai
achega
5912786d1b

+ 81 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -7960,5 +7960,86 @@ private void runMigration100() {
 		} catch (Exception e) {
 			log.warn("创建user_data_permissions表失败(可能已存在): " + e.getMessage());
 		}
+		ensureColumn("products", "min_stock_alert", "INT DEFAULT 0 COMMENT '库存预警阈值(低于此值预警)'");
+
+		// 迁移173: 创建库存管理4张表(inventory_transactions/counting_records/inbound_orders/inbound_items)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS inventory_transactions (" +
+					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+					"product_id BIGINT COMMENT '商品ID', " +
+					"sku_id BIGINT COMMENT 'SKU ID', " +
+					"type VARCHAR(32) COMMENT '交易类型: INBOUND/OUTBOUND/ADJUSTMENT/TRANSFER/COUNT', " +
+					"direction TINYINT COMMENT '方向: 1=入库, -1=出库', " +
+					"quantity INT NOT NULL COMMENT '数量', " +
+					"before_stock INT COMMENT '操作前库存', " +
+					"after_stock INT COMMENT '操作后库存', " +
+					"reference_type VARCHAR(32) COMMENT '关联业务类型', " +
+					"reference_id BIGINT COMMENT '关联业务ID', " +
+					"operator_id BIGINT COMMENT '操作人ID', " +
+					"remark TEXT COMMENT '备注', " +
+					"created_at DATETIME DEFAULT CURRENT_TIMESTAMP " +
+					") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存交易流水表'");
+			log.info("已创建inventory_transactions表");
+		} catch (Exception e) { /* 表已存在,忽略 */ }
+
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS inventory_counting_records (" +
+					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+					"product_id BIGINT COMMENT '商品ID', " +
+					"sku_id BIGINT COMMENT 'SKU ID', " +
+					"expected_stock INT COMMENT '账面库存', " +
+					"actual_stock INT COMMENT '实际库存', " +
+					"difference INT COMMENT '差异', " +
+					"status VARCHAR(16) DEFAULT 'pending' COMMENT '状态', " +
+					"operator_id BIGINT COMMENT '盘点人ID', " +
+					"remark TEXT COMMENT '备注', " +
+					"created_at DATETIME DEFAULT CURRENT_TIMESTAMP " +
+					") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存盘点记录表'");
+			log.info("已创建inventory_counting_records表");
+		} catch (Exception e) { /* 表已存在,忽略 */ }
+
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS inventory_inbound_orders (" +
+					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+					"order_no VARCHAR(64) NOT NULL COMMENT '入库单号', " +
+					"supplier_id BIGINT COMMENT '供应商ID', " +
+					"supplier_name VARCHAR(100) COMMENT '供应商名称', " +
+					"status VARCHAR(16) DEFAULT 'pending' COMMENT '状态', " +
+					"total_items INT DEFAULT 0 COMMENT '商品种类数', " +
+					"operator_id BIGINT COMMENT '操作人ID', " +
+					"remark TEXT COMMENT '备注', " +
+					"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+					"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP " +
+					") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存入库单表'");
+			log.info("已创建inventory_inbound_orders表");
+		} catch (Exception e) { /* 表已存在,忽略 */ }
+
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS inventory_inbound_items (" +
+					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+					"inbound_order_id BIGINT NOT NULL COMMENT '入库单ID', " +
+					"product_id BIGINT NOT NULL COMMENT '商品ID', " +
+					"sku_id BIGINT COMMENT 'SKU ID', " +
+					"quantity INT NOT NULL COMMENT '入库数量', " +
+					"created_at DATETIME DEFAULT CURRENT_TIMESTAMP " +
+					") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存入库明细表'");
+			log.info("已创建inventory_inbound_items表");
+		} catch (Exception e) { /* 表已存在,忽略 */ }
+
+		// 迁移174: product_skus 添加 min_stock_alert 和 min_quantity 列(SKU库存预警+最小购买数,配合库存管理模块)
+		ensureColumn("product_skus", "min_stock_alert", "INT DEFAULT 0 COMMENT '库存预警阈值(低于此值预警)'");
+		ensureColumn("product_skus", "min_quantity", "INT DEFAULT 1 COMMENT '最小购买数量限制'");
+
+		// 迁移175: inventory_inbound_orders 添加 inbound_date 和 total_amount 列(入库单扩展)
+		ensureColumn("inventory_inbound_orders", "inbound_date", "DATE COMMENT '入库日期'");
+		ensureColumn("inventory_inbound_orders", "total_amount", "INT DEFAULT 0 COMMENT '入库总金额(分)'");
+
+		// 迁移176: product_orders 添加 shipping_cost 列(快递费)
+		ensureColumn("product_orders", "shipping_cost", "INT DEFAULT 0 COMMENT '快递费(分)'");
+
+		// 迁移177: 添加进价字段(products/product_skus/inventory_inbound_items)
+		ensureColumn("products", "purchase_price", "DECIMAL(10,2) DEFAULT 0.00 COMMENT '进价(分)'");
+		ensureColumn("product_skus", "purchase_price", "DECIMAL(10,2) DEFAULT 0.00 COMMENT '进价(分)'");
+		ensureColumn("inventory_inbound_items", "purchase_price", "DECIMAL(10,2) DEFAULT 0.00 COMMENT '入库单价(分)'");
 	}
 }

+ 14 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/ProductDeliveryConfigService.java

@@ -22,6 +22,7 @@ public class ProductDeliveryConfigService {
     @Resource private ProductServicePersonMapper servicePersonMapper;
     @Resource private ProductServicePersonSlotMapper servicePersonSlotMapper;
     @Resource private ProductOnlineSlotMapper onlineSlotMapper;
+    @Resource private UserMapper userMapper;
 
     // ========== 商品详情组装 ==========
 
@@ -175,7 +176,19 @@ public class ProductDeliveryConfigService {
             ProductServicePerson person = new ProductServicePerson();
             person.setProductId(productId);
             Object userId = personData.get("userId");
-            if (userId != null) person.setUserId(Long.valueOf(userId.toString()));
+            if (userId == null) {
+                throw new IllegalArgumentException("服务者必须关联已入驻的服务商账号(userId不能为空)");
+            }
+            Long providerUserId = Long.valueOf(userId.toString());
+            User provider = userMapper.selectById(providerUserId);
+            if (provider == null) {
+                throw new IllegalArgumentException("服务者关联的用户不存在");
+            }
+            if (!"approved".equals(provider.getVendorStatus())) {
+                throw new IllegalArgumentException("服务者「" + (provider.getRealName() != null ? provider.getRealName() : providerUserId)
+                        + "」未通过服务商入驻审核,无法添加为线上服务者");
+            }
+            person.setUserId(providerUserId);
             person.setName((String) personData.get("name"));
             person.setTitle((String) personData.get("title"));
             person.setIntro((String) personData.get("intro"));

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/service/VendorService.java

@@ -27,7 +27,7 @@ public class VendorService {
         // 验证服务商类型
         if (vendorType != null) {
             java.util.List<String> validTypes = java.util.Arrays.asList(
-                    "planner", "activity_provider", "product_supplier", "consultant");
+                    "planner", "activity_provider", "product_supplier", "consultant", "nutritionist");
             if (!validTypes.contains(vendorType)) {
                 return Result.error("无效的服务商类型");
             }

+ 12 - 106
cfc-web/src/api/admin.js

@@ -49,135 +49,41 @@ export function vendorConfirmOrder(orderNo) {
   })
 }
 
-export function listDeliveryConfig(params) {
-  return request({
-    url: '/api/product/delivery-config/list',
-    method: 'post',
-    data: params
-  })
-}
-
-export function createPickupLocation(productId, data) {
-  return request({
-    url: '/api/product/pickup-location/create',
-    method: 'post',
-    data: { productId, ...data }
-  })
-}
-
-export function updatePickupLocation(id, data) {
-  return request({
-    url: '/api/product/pickup-location/update',
-    method: 'post',
-    data: { id, ...data }
-  })
-}
-
-export function deletePickupLocation(id) {
-  return request({
-    url: '/api/product/pickup-location/delete',
-    method: 'post',
-    data: { id }
-  })
-}
+// ===== 线上服务配置(批量接口,路由 /api/product/delivery-config/*)=====
 
-export function listPickupLocations(productId) {
+export function listDeliveryServicePersons(productId) {
   return request({
-    url: '/api/product/pickup-location/list',
+    url: '/api/product/delivery-config/service-person/list',
     method: 'post',
     data: { productId }
   })
 }
 
-export function createServicePerson(productId, data) {
+export function saveDeliveryServicePersons(productId, persons) {
   return request({
-    url: '/api/product/service-person/create',
+    url: '/api/product/delivery-config/service-person/save',
     method: 'post',
-    data: { productId, ...data }
-  })
-}
-
-export function updateServicePerson(id, data) {
-  return request({
-    url: '/api/product/service-person/update',
-    method: 'post',
-    data: { id, ...data }
-  })
-}
-
-export function deleteServicePerson(id) {
-  return request({
-    url: '/api/product/service-person/delete',
-    method: 'post',
-    data: { id }
+    data: { productId, persons }
   })
 }
 
-export function listServicePersons(productId) {
+export function listDeliveryOnlineSlots(productId) {
   return request({
-    url: '/api/product/service-person/list',
+    url: '/api/product/delivery-config/online-slot/list',
     method: 'post',
     data: { productId }
   })
 }
 
-export function createOnlineSlot(productId, data) {
+export function saveDeliveryOnlineSlots(productId, slots) {
   return request({
-    url: '/api/product/online-slot/create',
+    url: '/api/product/delivery-config/online-slot/save',
     method: 'post',
-    data: { productId, ...data }
+    data: { productId, slots }
   })
 }
 
-export function updateOnlineSlot(id, data) {
-  return request({
-    url: '/api/product/online-slot/update',
-    method: 'post',
-    data: { id, ...data }
-  })
-}
-
-export function deleteOnlineSlot(id) {
-  return request({
-    url: '/api/product/online-slot/delete',
-    method: 'post',
-    data: { id }
-  })
-}
-
-export function listOnlineSlots(productId) {
-  return request({
-    url: '/api/product/online-slot/list',
-    method: 'post',
-    data: { productId }
-  })
-}
-
-export function createPickupTimeSlot(locationId, data) {
-  return request({
-    url: '/api/product/pickup-time-slot/create',
-    method: 'post',
-    data: { locationId, ...data }
-  })
-}
-
-export function updatePickupTimeSlot(id, data) {
-  return request({
-    url: '/api/product/pickup-time-slot/update',
-    method: 'post',
-    data: { id, ...data }
-  })
-}
-
-export function deletePickupTimeSlot(id) {
-  return request({
-    url: '/api/product/pickup-time-slot/delete',
-    method: 'post',
-    data: { id }
-  })
-}
-
-// 家庭管理
+// 家庭管理
 export function getFamilyList(params) {
   return request({
     url: '/api/admin/families',

+ 192 - 1
cfc-web/src/views/admin/ProductEdit.vue

@@ -155,9 +155,70 @@
             <el-radio :label="1">快递</el-radio>
             <el-radio :label="2">自提</el-radio>
             <el-radio :label="3">两者皆可</el-radio>
+            <el-radio :label="4">线上-可选人</el-radio>
+            <el-radio :label="5">线上-单人</el-radio>
           </el-radio-group>
         </el-form-item>
 
+        <!-- 线上-可选人(4):服务者配置(每个服务者=一个服务商,可独立定价+可预约时段) -->
+        <el-form-item v-if="form.deliveryMethod === 4" label="线上服务者">
+          <div class="service-person-list">
+            <div v-for="(person, pIdx) in servicePersons" :key="pIdx" class="service-person-card">
+              <div class="sp-header">
+                <span class="sp-title">服务者 {{ pIdx + 1 }}</span>
+                <el-button type="text" size="mini" @click="removeServicePerson(pIdx)">删除</el-button>
+              </div>
+              <el-row :gutter="10">
+                <el-col :span="8">
+                  <el-input v-model="person.userId" placeholder="服务商用户ID" />
+                </el-col>
+                <el-col :span="8">
+                  <el-input v-model="person.name" placeholder="展示名称" />
+                </el-col>
+                <el-col :span="8">
+                  <el-input v-model="person.title" placeholder="头衔(如高级营养师)" />
+                </el-col>
+              </el-row>
+              <el-row :gutter="10" style="margin-top: 8px">
+                <el-col :span="8">
+                  <el-input v-model="person.avatar" placeholder="头像URL" />
+                </el-col>
+                <el-col :span="8">
+                  <el-input v-model.number="person.price" placeholder="专属价格(元)" />
+                </el-col>
+                <el-col :span="8">
+                  <el-input v-model.number="person.originalPrice" placeholder="原价(元)" />
+                </el-col>
+              </el-row>
+              <el-input v-model="person.intro" type="textarea" :rows="2" placeholder="个人介绍" style="margin-top: 8px" />
+              <div class="sp-slots">
+                <div v-for="(slot, sIdx) in person.slots" :key="sIdx" class="sp-slot-row">
+                  <el-date-picker v-model="slot.startTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="开始时间" style="width: 190px" />
+                  <el-date-picker v-model="slot.endTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="结束时间" style="width: 190px; margin-left: 8px" />
+                  <el-input v-model.number="slot.maxCapacity" placeholder="容量" style="width: 90px; margin-left: 8px" />
+                  <el-button type="text" size="mini" @click="removeServicePersonSlot(pIdx, sIdx)">删除</el-button>
+                </div>
+                <el-button type="text" size="mini" @click="addServicePersonSlot(pIdx)">+ 添加时段</el-button>
+              </div>
+            </div>
+            <el-button type="primary" plain size="mini" @click="addServicePerson">+ 添加服务者</el-button>
+            <div class="delivery-config-hint">服务者必须已通过服务商入驻审核(userId 为系统用户ID)</div>
+          </div>
+        </el-form-item>
+
+        <!-- 线上-单人(5):固定服务时段配置 -->
+        <el-form-item v-if="form.deliveryMethod === 5" label="线上服务时段">
+          <div class="online-slot-list">
+            <div v-for="(slot, idx) in onlineSlots" :key="idx" class="sp-slot-row">
+              <el-date-picker v-model="slot.startTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="开始时间" style="width: 190px" />
+              <el-date-picker v-model="slot.endTime" type="datetime" value-format="yyyy-MM-dd HH:mm:ss" placeholder="结束时间" style="width: 190px; margin-left: 8px" />
+              <el-input v-model.number="slot.maxCapacity" placeholder="容量" style="width: 90px; margin-left: 8px" />
+              <el-button type="text" size="mini" @click="removeOnlineSlot(idx)">删除</el-button>
+            </div>
+            <el-button type="primary" plain size="mini" @click="addOnlineSlot">+ 添加时段</el-button>
+          </div>
+        </el-form-item>
+
         <el-form-item label="会员专属">
           <el-radio-group v-model="form.memberEligible">
             <el-radio label="no">否</el-radio>
@@ -226,7 +287,7 @@
 </template>
 
 <script>
-import { getProduct, createProduct, updateProduct, listAllSupplySystems, getPredefinedPurchaseFields, getProductPurchaseFields, saveProductPurchaseFields, getMySupplySystem, saveProductGiftRule, listProductGiftRules, listMembershipLevels, getGrowthArchiveProduct, saveGrowthArchiveProduct } from '@/api/admin'
+import { getProduct, createProduct, updateProduct, listAllSupplySystems, getPredefinedPurchaseFields, getProductPurchaseFields, saveProductPurchaseFields, getMySupplySystem, saveProductGiftRule, listProductGiftRules, listMembershipLevels, getGrowthArchiveProduct, saveGrowthArchiveProduct, listDeliveryServicePersons, saveDeliveryServicePersons, listDeliveryOnlineSlots, saveDeliveryOnlineSlots } from '@/api/admin'
 import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight'
 import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
 import ProductSkuEditor from './components/ProductSkuEditor.vue'
@@ -260,6 +321,8 @@ export default {
         validityDays: 365,
         guideScope: 'all'
       },
+      servicePersons: [], // 线上服务者(deliveryMethod=4): [{userId,name,title,intro,avatar,price,originalPrice,slots:[{startTime,endTime,maxCapacity}]}]
+      onlineSlots: [], // 单人线上时段(deliveryMethod=5): [{startTime,endTime,maxCapacity}]
       growthArchive: {
         enabled: false,
         sortOrder: 0
@@ -418,6 +481,8 @@ export default {
           this.loadGiftRule(p.id)
           // Load growth archive config
           this.loadGrowthArchive(p.id)
+          // Load delivery config (service persons / online slots)
+          this.loadDeliveryConfig(p.id)
           // v-model 已绑定 form.description,editor 内容自动更新
         }
       } catch (e) {
@@ -502,6 +567,89 @@ async loadGiftRule(productId) {
         console.warn('加载成长档案关联配置失败', e)
       }
     },
+    async loadDeliveryConfig(productId) {
+      if (this.form.deliveryMethod === 4) {
+        try {
+          const res = await listDeliveryServicePersons(productId)
+          if (res.code === 200 && Array.isArray(res.data)) {
+            this.servicePersons = res.data.map(p => ({
+              userId: p.userId || '',
+              name: p.name || '',
+              title: p.title || '',
+              intro: p.intro || '',
+              avatar: p.avatar || '',
+              price: (p.price != null) ? p.price / 100 : null,
+              originalPrice: (p.originalPrice != null) ? p.originalPrice / 100 : null,
+              slots: (p.slots || []).map(s => ({
+                startTime: (s.startTime || '').replace('T', ' ').substring(0, 19),
+                endTime: (s.endTime || '').replace('T', ' ').substring(0, 19),
+                maxCapacity: s.maxCapacity || 1
+              }))
+            }))
+          }
+        } catch (e) {
+          console.warn('加载线上服务者失败', e)
+        }
+      } else if (this.form.deliveryMethod === 5) {
+        try {
+          const res = await listDeliveryOnlineSlots(productId)
+          if (res.code === 200 && Array.isArray(res.data)) {
+            this.onlineSlots = res.data.map(s => ({
+              startTime: (s.startTime || '').replace('T', ' ').substring(0, 19),
+              endTime: (s.endTime || '').replace('T', ' ').substring(0, 19),
+              maxCapacity: s.maxCapacity || 1
+            }))
+          }
+        } catch (e) {
+          console.warn('加载线上服务时段失败', e)
+        }
+      }
+    },
+    addServicePerson() {
+      this.servicePersons.push({ userId: '', name: '', title: '', intro: '', avatar: '', price: null, originalPrice: null, slots: [] })
+    },
+    removeServicePerson(idx) {
+      this.servicePersons.splice(idx, 1)
+    },
+    addServicePersonSlot(pIdx) {
+      this.servicePersons[pIdx].slots.push({ startTime: '', endTime: '', maxCapacity: 1 })
+    },
+    removeServicePersonSlot(pIdx, sIdx) {
+      this.servicePersons[pIdx].slots.splice(sIdx, 1)
+    },
+    addOnlineSlot() {
+      this.onlineSlots.push({ startTime: '', endTime: '', maxCapacity: 1 })
+    },
+    removeOnlineSlot(idx) {
+      this.onlineSlots.splice(idx, 1)
+    },
+    buildServicePersonPayload() {
+      if (this.form.deliveryMethod !== 4) return []
+      return this.servicePersons
+        .filter(p => p.userId)
+        .map(p => ({
+          userId: Number(p.userId),
+          name: p.name || '',
+          title: p.title || '',
+          intro: p.intro || '',
+          avatar: p.avatar || '',
+          price: (p.price != null) ? Math.round(p.price * 100) : 0,
+          originalPrice: (p.originalPrice != null) ? Math.round(p.originalPrice * 100) : null,
+          slots: (p.slots || []).filter(s => s.startTime && s.endTime).map(s => ({
+            startTime: s.startTime,
+            endTime: s.endTime,
+            maxCapacity: s.maxCapacity || 1
+          }))
+        }))
+    },
+    buildOnlineSlotPayload() {
+      if (this.form.deliveryMethod !== 5) return []
+      return this.onlineSlots.filter(s => s.startTime && s.endTime).map(s => ({
+        startTime: s.startTime,
+        endTime: s.endTime,
+        maxCapacity: s.maxCapacity || 1
+      }))
+    },
     handlePurchaseFieldsToggle(val) {
       if (!val) {
         this.predefinedFields.forEach(pf => { pf.selected = false })
@@ -638,6 +786,16 @@ async loadGiftRule(productId) {
                 console.warn('保存成长档案关联失败', e)
               }
             }
+            // Save delivery config (service persons / online slots)
+            if (productId) {
+              try {
+                await saveDeliveryServicePersons(productId, this.buildServicePersonPayload())
+                await saveDeliveryOnlineSlots(productId, this.buildOnlineSlotPayload())
+              } catch (e) {
+                this.$message.error('保存线上服务配置失败:' + (e.message || e))
+                console.warn('保存线上服务配置失败', e)
+              }
+            }
             this.$message.success(this.isEdit ? '保存成功' : '创建成功')
             this.$router.push('/product-manage')
           } else {
@@ -794,4 +952,37 @@ async loadGiftRule(productId) {
   color: #ccc;
   cursor: move;
 }
+.service-person-card {
+  border: 1px solid #e8e8e8;
+  border-radius: 4px;
+  padding: 10px;
+  margin-bottom: 10px;
+  background: #fafafa;
+}
+.sp-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8px;
+}
+.sp-title {
+  font-weight: 600;
+  font-size: 13px;
+}
+.sp-slots {
+  margin-top: 8px;
+  padding: 8px;
+  background: #fff;
+  border-radius: 4px;
+}
+.sp-slot-row {
+  display: flex;
+  align-items: center;
+  margin-bottom: 6px;
+}
+.delivery-config-hint {
+  margin-top: 6px;
+  font-size: 12px;
+  color: #999;
+}
 </style>