Selaa lähdekoodia

feat(ppoint): P点口径改为成本价核算 + 多商品明细分摊落库

- products.purchase_price DECIMAL→INT(分),成本价用于P点=实付-成本(迁移430)
- cf_direct/indirect_sales_tier 门槛×100 + 档位名 新秀/小成/中富/大成/至富(迁移431)
- calcOrderPpoints 重写:单商品 实付-成本×数量;多商品按行单价×数量占比分摊实付再减行成本(负毛利记0);非商品沿用服务费率
- createMultiItem 落库 order_items 明细行(结算按行分摊前提)
- Product.purchasePrice 类型 BigDecimal→Integer(分)
- ProductEdit.vue 增加成本价(元)输入(落库转分)
- ProductDTO 输出 purchasePrice
Xiaogang Liao 1 viikko sitten
vanhempi
sitoutus
c22aca4966

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

@@ -3382,6 +3382,59 @@ insertSysConfigSeed("gift_membership_pay_price", "198000", "送会员商品达
 
         // 迁移429: 用户关注指标收藏表 indicator_favorite(T04 方案A:后端持久化,跨端同步)
         createIndicatorFavoriteTable();
+
+        // 迁移430: 商品成本价改为整型(分) —— P点口径:P点 = 实际成交额 - 成本价(分)
+        migrateProductsPurchasePriceToCents();
+        // 迁移431: 销售奖档位门槛 ×100(元→分)+ 档位名改为 新秀/小成/中富/大成/至富
+        migrateSalesAwardTierThresholds();
+    }
+
+    /**
+     * 迁移430: 商品成本价改为整型(分)(P点口径调整)。
+     *
+     * <p>原 products.purchase_price DECIMAL(10,2),值与 price 同量级(分);改为 INT 存分。
+     * 仅改类型不做数值换算(幂等:已是 INT 则跳过)。</p>
+     */
+    private void migrateProductsPurchasePriceToCents() {
+        try {
+            String colType = jdbcTemplate.queryForObject(
+                    "SELECT DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() "
+                            + "AND TABLE_NAME='products' AND COLUMN_NAME='purchase_price'", String.class);
+            if ("decimal".equalsIgnoreCase(colType)) {
+                // 值本身已是分(与 price 同量级、低于售价),只改类型不做数值换算
+                jdbcTemplate.execute("ALTER TABLE products MODIFY COLUMN purchase_price INT NOT NULL DEFAULT 0 COMMENT '成本价(分)'");
+                log.info("迁移430完成:products.purchase_price decimal→int(分)(值已是分,仅改类型)");
+            } else {
+                log.info("迁移430跳过:products.purchase_price 已是 {} 类型", colType);
+            }
+        } catch (Exception e) {
+            log.warn("迁移430失败(幂等容忍): {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 迁移431: 销售奖档位门槛由元改为分(×100),并按 id 重命名为新档位名(费率不变)。
+     *
+     * <p>档位:新秀(1w)/小成(5w)/中富(20w)/大成(50w)/至富(100w),单位分。</p>
+     */
+    private void migrateSalesAwardTierThresholds() {
+        try {
+            jdbcTemplate.execute(
+                    "UPDATE cf_direct_sales_tier SET min_annual_ppoints = CASE id "
+                            + "WHEN 1 THEN 10000 WHEN 2 THEN 50000 WHEN 3 THEN 200000 WHEN 4 THEN 500000 WHEN 5 THEN 1000000 END, "
+                            + "tier_name = CASE id WHEN 1 THEN '新秀' WHEN 2 THEN '小成' WHEN 3 THEN '中富' "
+                            + "WHEN 4 THEN '大成' WHEN 5 THEN '至富' END "
+                            + "WHERE id BETWEEN 1 AND 5");
+            jdbcTemplate.execute(
+                    "UPDATE cf_indirect_sales_tier SET min_self_ppoints = CASE id "
+                            + "WHEN 1 THEN 10000 WHEN 2 THEN 50000 WHEN 3 THEN 200000 WHEN 4 THEN 500000 WHEN 5 THEN 1000000 END, "
+                            + "tier_name = CASE id WHEN 1 THEN '新秀' WHEN 2 THEN '小成' WHEN 3 THEN '中富' "
+                            + "WHEN 4 THEN '大成' WHEN 5 THEN '至富' END "
+                            + "WHERE id BETWEEN 1 AND 5");
+            log.info("迁移431完成:销售奖档位门槛×100(分),档位名新秀/小成/中富/大成/至富");
+        } catch (Exception e) {
+            log.warn("迁移431失败(幂等容忍): {}", e.getMessage());
+        }
     }
 
     /**

+ 2 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductDTO.java

@@ -18,6 +18,7 @@ public class ProductDTO {
     private String coverImage;
     private String images;
     private Integer price;
+    private Integer purchasePrice;  // 成本价(分)
     private Integer memberPrice;
     private Integer stock;
     private Integer minQuantity;
@@ -140,6 +141,7 @@ public class ProductDTO {
         d.updatedAt = p.getUpdatedAt();
         // 会员体系重构:商品对所有用户统一定价(活动侧保留会员价,商品侧停用)
         d.price = p.getPrice();
+        d.purchasePrice = p.getPurchasePrice();
         if (d.price != null && d.price == 0) {
             d.priceLabel = "免费";
         } else {

+ 1 - 1
cfc-backend/src/main/java/com/etotem/cfc/entity/Product.java

@@ -27,7 +27,7 @@ public class Product implements Serializable {
     private String coverImage;
     private String images;
     private Integer price;       // 价格(分)
-    private java.math.BigDecimal purchasePrice; // 进价(分)
+    private Integer purchasePrice; // 成本价(分)
     private Integer stock;
     private Integer minStockAlert;
     private Integer salesCount;

+ 66 - 12
cfc-backend/src/main/java/com/etotem/cfc/service/CfCommissionService.java

@@ -12,7 +12,9 @@ import com.etotem.cfc.entity.OctopusEffectKeyPerson;
 import com.etotem.cfc.entity.OctopusEffectRecord;
 import com.etotem.cfc.entity.OctopusKeyPerson;
 import com.etotem.cfc.entity.PpointConsumptionLog;
+import com.etotem.cfc.entity.OrderItem;
 import com.etotem.cfc.entity.Product;
+import com.etotem.cfc.entity.ProductOrder;
 import com.etotem.cfc.entity.ReferralTree;
 import com.etotem.cfc.entity.User;
 import com.etotem.cfc.mapper.FamilyMemberMapper;
@@ -21,6 +23,8 @@ import com.etotem.cfc.mapper.OctopusEffectRecordMapper;
 import com.etotem.cfc.mapper.OctopusKeyPersonMapper;
 import com.etotem.cfc.mapper.PpointConsumptionLogMapper;
 import com.etotem.cfc.mapper.ProductMapper;
+import com.etotem.cfc.mapper.OrderItemMapper;
+import com.etotem.cfc.mapper.ProductOrderMapper;
 import com.etotem.cfc.mapper.ReferralTreeMapper;
 import com.etotem.cfc.mapper.UserMapper;
 import com.fasterxml.jackson.core.type.TypeReference;
@@ -92,6 +96,10 @@ public class CfCommissionService {
     @Resource
     private ProductMapper productMapper;
     @Resource
+    private OrderItemMapper orderItemMapper;
+    @Resource
+    private ProductOrderMapper productOrderMapper;
+    @Resource
     private OctopusKeyPersonMapper octopusKeyPersonMapper;
     @Resource
     private OctopusEffectRecordMapper octopusEffectRecordMapper;
@@ -133,7 +141,8 @@ public class CfCommissionService {
         // 当前消费人返 CF(F02 统一账本:1 元 = 100 CF,按实付金额)
         earnBuyerCf(orderId, orderType, buyerUserId, paidAmountCent);
         // 推荐人分润(两级销售奖)
-        distributeToReferrers(orderId, orderType, buyerUserId, buyerFamilyId, orderAmountCent, productId);
+        distributeToReferrers(orderId, orderType, buyerUserId, buyerFamilyId,
+                orderAmountCent, paidAmountCent, productId);
     }
 
     /**
@@ -157,7 +166,8 @@ public class CfCommissionService {
     public void settleReferrerOnly(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
                                    Integer orderAmountCent, Integer paidAmountCent) {
         earnBuyerCf(orderId, orderType, buyerUserId, paidAmountCent);
-        distributeToReferrers(orderId, orderType, buyerUserId, buyerFamilyId, orderAmountCent, null);
+        distributeToReferrers(orderId, orderType, buyerUserId, buyerFamilyId,
+                orderAmountCent, paidAmountCent, null);
     }
 
     /**
@@ -184,12 +194,12 @@ public class CfCommissionService {
      * </ul>
      */
     private void distributeToReferrers(Long orderId, String orderType, Long buyerUserId, Long buyerFamilyId,
-                                       Integer orderAmountCent, Long productId) {
+                                   Integer orderAmountCent, Integer paidAmountCent, Long productId) {
         if (orderAmountCent == null || orderAmountCent <= 0) return;
         int amountYuan = orderAmountCent / 100;
 
         // 本单消费产生的 P 点:F03a / F03b 的共同基数(§4.4)
-        int basePpoints = calcOrderPpoints(orderType, amountYuan, productId);
+        int basePpoints = calcOrderPpoints(orderId, orderType, orderAmountCent, paidAmountCent, productId);
         if (basePpoints <= 0) {
             log.info("本单 P 点基数为 0,跳过销售奖: orderType={}, orderId={}", orderType, orderId);
             return;
@@ -320,22 +330,66 @@ public class CfCommissionService {
     }
 
     /**
-     * 本单消费产生的 P 点(销售奖基数)。
-     * 商品:实付(元) × 有效 P 点 / 100;非商品:实付(元) × 服务费率(bps) / 10000。
+     * 本单消费产生的 P 点(销售奖基数,单位:分)。
+     *
+     * <p>口径:P 点 = 实际成交额 − 成本价({@code products.purchase_price},分)。</p>
+     * <ul>
+     *   <li>单商品订单:实付 − 成本价 × 数量;</li>
+     *   <li>多商品订单({@code order_items} 有明细):按各行 单价×数量 占比分摊实付,
+     *       再减各行成本价×数量,各行毛利(负数记 0)求和(方案A);</li>
+     *   <li>亏本销售不发:P 点 = max(0, ...);</li>
+     *   <li>非商品订单(会员/套餐/订阅/测评):沿用服务费率口径。</li>
+     * </ul>
      */
-    private int calcOrderPpoints(String orderType, int amountYuan, Long productId) {
-        if ("product".equals(orderType) && productId != null) {
+    private int calcOrderPpoints(Long orderId, String orderType, Integer amountCent, Integer paidAmountCent, Long productId) {
+        if ("product".equals(orderType)) {
             try {
+                int paid = paidAmountCent != null ? paidAmountCent : (amountCent != null ? amountCent : 0);
+                if (paid <= 0) return 0;
+                ProductOrder order = productOrderMapper.selectById(orderId);
+                if (order == null) return 0;
+
+                // 多商品订单:order_items 有明细 → 按行分摊实付 - 行成本(方案A)
+                List<OrderItem> items = orderItemMapper.findByOrderId(orderId);
+                if (items != null && !items.isEmpty()) {
+                    long totalBase = 0L;
+                    for (OrderItem it : items) {
+                        int qty = it.getQuantity() != null ? it.getQuantity() : 0;
+                        long base = (long) Math.round((it.getPrice() != null ? it.getPrice().doubleValue() : 0d) * qty);
+                        if (base > 0) totalBase += base;
+                    }
+                    if (totalBase > 0) {
+                        int total = 0;
+                        for (OrderItem it : items) {
+                            int qty = it.getQuantity() != null ? it.getQuantity() : 0;
+                            if (qty <= 0) continue;
+                            long base = (long) Math.round((it.getPrice() != null ? it.getPrice().doubleValue() : 0d) * qty);
+                            if (base <= 0) continue;
+                            long allocPaid = (long) ((double) paid * base / totalBase);
+                            Product p = productMapper.selectById(it.getProductId());
+                            int costPerUnit = (p != null && p.getPurchasePrice() != null) ? p.getPurchasePrice() : 0;
+                            long lineCost = (long) costPerUnit * qty;
+                            long linePpoint = allocPaid - lineCost;
+                            if (linePpoint > 0) total += (int) linePpoint;
+                        }
+                        return total;
+                    }
+                }
+
+                // 单商品订单:实付 − 成本 × 数量
+                if (productId == null) return 0;
                 Product p = productMapper.selectById(productId);
                 if (p == null) return 0;
-                int effectivePpoint = ppointConfigService.getEffectivePpoint(productId, p.getCategoryId());
-                if (effectivePpoint <= 0) return 0;
-                return amountYuan * effectivePpoint / 100;
+                int costPerUnit = p.getPurchasePrice() != null ? p.getPurchasePrice() : 0;
+                int qty = order.getQuantity() != null ? order.getQuantity() : 1;
+                int ppoints = paid - costPerUnit * qty;
+                return ppoints > 0 ? ppoints : 0;
             } catch (Exception e) {
-                log.warn("计算本单 P 点失败: productId={}, err={}", productId, e.getMessage());
+                log.warn("计算本单 P 点失败: orderId={}, err={}", orderId, e.getMessage());
                 return 0;
             }
         }
+        int amountYuan = (amountCent != null ? amountCent : 0) / 100;
         int serviceRateBps = configService.getIntConfig(CfConfigKeys.COMMISSION_SERVICE_RATE, 1000);
         if (serviceRateBps <= 0) return 0;
         return amountYuan * serviceRateBps / 10000;

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

@@ -285,7 +285,7 @@ public class NutritionProductService {
         nutritionProduct.setDescription(product.getDescription());
         nutritionProduct.setImageUrl(product.getCoverImage());
         nutritionProduct.setPrice(BigDecimal.valueOf(product.getPrice() != null ? product.getPrice() : 0));
-        nutritionProduct.setOriginalPrice(product.getPurchasePrice() != null ? product.getPurchasePrice().intValue() : 0);
+        nutritionProduct.setOriginalPrice(product.getPurchasePrice() != null ? product.getPurchasePrice() : 0);
         nutritionProduct.setSuitableFor("全家人");
         nutritionProduct.setCreateTime(new Date());
         nutritionProduct.setUpdateTime(new Date());

+ 23 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/ProductOrderService.java

@@ -18,6 +18,7 @@ import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.entity.ProductBundleItem;
 import com.etotem.cfc.entity.ProductGiftRule;
 import com.etotem.cfc.entity.ProductOnlineSlot;
+import com.etotem.cfc.entity.OrderItem;
 import com.etotem.cfc.entity.ProductOrder;
 import com.etotem.cfc.entity.ProductPickupLocation;
 import com.etotem.cfc.entity.ProductPickupTimeSlot;
@@ -186,6 +187,9 @@ public class ProductOrderService {
     @Resource
     private com.etotem.cfc.mapper.ProductOnlineSlotMapper productOnlineSlotMapper;
 
+    @Resource
+    private com.etotem.cfc.mapper.OrderItemMapper orderItemMapper;
+
     @Resource
     private com.etotem.cfc.mapper.ConsigneeMapper consigneeMapper;
 
@@ -738,6 +742,7 @@ public class ProductOrderService {
         int totalQuantity = 0;
         int giftAmount = 0;
         OrderItemVO firstItem = dto.getItems().get(0);
+        java.util.List<OrderItem> orderItemRecords = new java.util.ArrayList<>();
         Product firstProduct = null;
         int firstUnitPrice = 0;
 
@@ -798,6 +803,17 @@ public class ProductOrderService {
                 firstProduct = product;
                 firstUnitPrice = unitPrice;
             }
+            // 订单明细行(多商品 P 点按行分摊的依据;单价/小计单位:分)
+            OrderItem itemRecord = new OrderItem();
+            itemRecord.setProductId(product.getId());
+            itemRecord.setSkuId(item.getSkuId());
+            itemRecord.setProductName(product.getName());
+            itemRecord.setProductImage(product.getCoverImage());
+            itemRecord.setSkuSpecs(item.getSpecOptionIds());
+            itemRecord.setPrice(java.math.BigDecimal.valueOf(unitPrice));
+            itemRecord.setQuantity(item.getQuantity());
+            itemRecord.setSubtotal(java.math.BigDecimal.valueOf((long) unitPrice * item.getQuantity()));
+            orderItemRecords.add(itemRecord);
         }
 
         User buyer = userMapper.selectById(buyerId);
@@ -842,6 +858,13 @@ public class ProductOrderService {
         order.setCreatedAt(new Date());
         order.setUpdatedAt(new Date());
         orderMapper.insert(order);
+        // 多商品订单明细分行落库(结算 P 点按行成本分摊,见 CfCommissionService.calcOrderPpoints)
+        if (!orderItemRecords.isEmpty()) {
+            for (OrderItem itemRecord : orderItemRecords) {
+                itemRecord.setOrderId(order.getId());
+                orderItemMapper.insert(itemRecord);
+            }
+        }
         // 占用已选优惠券(避免 pending 期间其他订单重复使用同一张)
         if (appliedCouponId != null) {
             try {

+ 1 - 1
cfc-backend/src/main/resources/schema.sql

@@ -1163,7 +1163,7 @@ CREATE TABLE IF NOT EXISTS products (
     cover_image VARCHAR(500) COMMENT '封面图片',
     images TEXT COMMENT '多张图片JSON',
     price INT NOT NULL DEFAULT 0 COMMENT '价格(分)',
-    purchase_price DECIMAL(10,2) DEFAULT 0.00 COMMENT '进价(分)',
+    purchase_price INT NOT NULL DEFAULT 0 COMMENT '成本价(分)',
     stock INT NOT NULL DEFAULT 0 COMMENT '库存',
     min_stock_alert INT DEFAULT 0 COMMENT '库存预警阈值(低于此值预警)',
     sales_count INT NOT NULL DEFAULT 0 COMMENT '销量',

+ 8 - 0
cfc-web/src/views/admin/ProductEdit.vue

@@ -181,6 +181,11 @@
               <el-input-number v-model="form.memberPrice" :min="0" :precision="2" :step="0.01" placeholder="留空等于普通价" class="full-width" controls />
             </el-form-item>
           </el-col>
+          <el-col :span="6">
+            <el-form-item label="成本价(元)">
+              <el-input-number v-model="form.purchasePrice" :min="0" :precision="2" :step="0.01" placeholder="用于P点=实付-成本" class="full-width" controls />
+            </el-form-item>
+          </el-col>
           <el-col :span="6">
             <el-form-item label="库存" prop="stock">
               <el-input-number v-model="form.stock" :min="0" :precision="0" placeholder="0" class="full-width" controls />
@@ -407,6 +412,7 @@ export default {
         productType: 'physical',
         price: null,
         memberPrice: null,
+        purchasePrice: null,
         stock: null,
         minQuantity: 1,
         vendorName: '',
@@ -707,6 +713,7 @@ export default {
             productType: p.productType || 'physical',
             price: (p.price != null) ? p.price / 100 : null,
             memberPrice: (p.memberPrice != null && p.memberPrice > 0) ? p.memberPrice / 100 : null,
+            purchasePrice: (p.purchasePrice != null) ? p.purchasePrice / 100 : null,
             stock: (p.stock != null) ? p.stock : null,
             minQuantity: p.minQuantity || null,
             vendorName: p.vendorName || '',
@@ -1092,6 +1099,7 @@ async loadGiftRule(productId) {
             ...this.form,
             price: (this.form.price != null) ? Math.round(this.form.price * 100) : 0,
             memberPrice: (this.form.memberPrice != null && this.form.memberPrice > 0) ? Math.round(this.form.memberPrice * 100) : 0,
+            purchasePrice: (this.form.purchasePrice != null && this.form.purchasePrice > 0) ? Math.round(this.form.purchasePrice * 100) : 0,
             images: JSON.stringify(this.imageList)
           }
           // Extract dimensionWeights before sending to product API