feat(pdd): 发品价格按建议零售价折算(七五折+单买加分),market_price 与满折配置

Made-with: Cursor
This commit is contained in:
huangyujie 2026-03-25 16:42:34 +08:00
parent e5061125a2
commit 89c239149c
4 changed files with 86 additions and 39 deletions

View File

@ -43,6 +43,12 @@ external:
auto-fetch-cat-rule: false auto-fetch-cat-rule: false
# sku-overrides 为空时按类目规则 + spec.id 自动拼规格(与 SKU 条数一致时才继续调 goods.add # sku-overrides 为空时按类目规则 + spec.id 自动拼规格(与 SKU 条数一致时才继续调 goods.add
auto-resolve-spec-ids-when-sku-overrides-empty: true auto-resolve-spec-ids-when-sku-overrides-empty: true
# 满件折 two_pieces_discount默认 9999 折)
two-pieces-discount: 99
# 拼单价 = 建议零售价 × (本值/100),默认 75=七五折
group-buy-discount-percent: 75
# 单买价(分)= 拼单价(分)+ 本值,默认 200=2 元
single-buy-add-fen: 200
# categoryCode请求体-> 拼多多叶子类目 cat_id与 maindata default-category-code=DEFAULT 对齐) # categoryCode请求体-> 拼多多叶子类目 cat_id与 maindata default-category-code=DEFAULT 对齐)
category-map: category-map:
DEFAULT: "15693" DEFAULT: "15693"

View File

@ -46,6 +46,9 @@ external:
gateway-url: https://gw-api.pinduoduo.com/api/router gateway-url: https://gw-api.pinduoduo.com/api/router
auto-fetch-cat-rule: false auto-fetch-cat-rule: false
auto-resolve-spec-ids-when-sku-overrides-empty: true auto-resolve-spec-ids-when-sku-overrides-empty: true
two-pieces-discount: 99
group-buy-discount-percent: 75
single-buy-add-fen: 200
category-map: category-map:
DEFAULT: "15693" DEFAULT: "15693"
cost-template-map: cost-template-map:

View File

@ -46,6 +46,21 @@ public class ExternalPddProperties {
private boolean defaultIsRefundable = true; private boolean defaultIsRefundable = true;
private boolean defaultSecondHand = false; private boolean defaultSecondHand = false;
/**
* {@code pdd.goods.add} 根级 {@code two_pieces_discount}满件折默认 99 表示 99
*/
private int twoPiecesDiscount = 99;
/**
* 拼单价 = 建议零售价 ×本值/100默认 75 即七五折为百分比整数非分
*/
private int groupBuyDiscountPercent = 75;
/**
* 单买价= 拼单价+ 本值须为正默认 200=2
*/
private long singleBuyAddFen = 200L;
/** /**
* 与单次请求 {@code skus} 列表顺序一一对应过滤掉 null 后的顺序 * 与单次请求 {@code skus} 列表顺序一一对应过滤掉 null 后的顺序
* 每条对应 PDD sku_list 一项的 spec_id_list sku_properties * 每条对应 PDD sku_list 一项的 spec_id_list sku_properties

View File

@ -19,6 +19,8 @@ import java.util.Map;
/** /**
* Canonical 落库结果组装为 {@code pdd.goods.add} <strong>单根业务 JSON</strong>字段名为 POP 下划线形式 * Canonical 落库结果组装为 {@code pdd.goods.add} <strong>单根业务 JSON</strong>字段名为 POP 下划线形式
* {@link cn.qihangerp.service.external.pdd.PddPopClient#invokeGoodsAdd} 会将其展开为官方网关要求的<strong>表单顶层</strong>字段 POP curl / SDK 一致不使用 {@code param_json} * {@link cn.qihangerp.service.external.pdd.PddPopClient#invokeGoodsAdd} 会将其展开为官方网关要求的<strong>表单顶层</strong>字段 POP curl / SDK 一致不使用 {@code param_json}
* <p>拼多多售价 SKU {@code retailPrice}档案建议零售价为基准拼单价=建议零售价×{@code external.pdd.group-buy-discount-percent}%
* 单买价=拼单价+{@code external.pdd.single-buy-add-fen}{@code market_price} 取各 SKU 建议零售价之最大必要时高于单买价以满足校验</p>
* *
* @author guochengyu * @author guochengyu
*/ */
@ -68,6 +70,7 @@ public class PddGoodsAddParamBuilder {
root.put("is_refundable", props.isDefaultIsRefundable()); root.put("is_refundable", props.isDefaultIsRefundable());
root.put("second_hand", props.isDefaultSecondHand()); root.put("second_hand", props.isDefaultSecondHand());
root.put("shipment_limit_second", resolveShipmentSeconds(req, props)); root.put("shipment_limit_second", resolveShipmentSeconds(req, props));
root.put("two_pieces_discount", props.getTwoPiecesDiscount());
List<String> carousel = resolveCarousel(goods, req); List<String> carousel = resolveCarousel(goods, req);
if (carousel.isEmpty()) { if (carousel.isEmpty()) {
@ -82,7 +85,7 @@ public class PddGoodsAddParamBuilder {
} }
root.put("detail_gallery", detail); root.put("detail_gallery", detail);
long marketFen = resolveMarketPriceFen(req, skuRows); long maxSuggestedFen = maxSuggestedRetailFen(skuRows);
if (StringUtils.hasText(goods.getOuterErpGoodsId())) { if (StringUtils.hasText(goods.getOuterErpGoodsId())) {
root.put("out_goods_id", goods.getOuterErpGoodsId()); root.put("out_goods_id", goods.getOuterErpGoodsId());
@ -97,14 +100,9 @@ public class PddGoodsAddParamBuilder {
if (!StringUtils.hasText(ov.getSpecIdList())) { if (!StringUtils.hasText(ov.getSpecIdList())) {
throw new IllegalStateException("external.pdd.sku-overrides[" + i + "].spec-id-list 不能为空"); throw new IllegalStateException("external.pdd.sku-overrides[" + i + "].spec-id-list 不能为空");
} }
long groupFen = yuanToFen(firstNonNull(row.getGroupPrice(), row.getRetailPrice())); long[] gs = groupAndSingleFenFromSuggestedRetail(row.getRetailPrice(), props, row.getOuterErpSkuId());
long singleFen = yuanToFen(firstNonNull(row.getRetailPrice(), row.getGroupPrice())); long groupFen = gs[0];
if (groupFen <= 0) { long singleFen = gs[1];
throw new IllegalStateException("SKU 团购价/拼团价无效groupPrice/salePrice 换算为 0 分outSkuId=" + row.getOuterErpSkuId());
}
if (singleFen <= groupFen) {
singleFen = groupFen + 100;
}
if (singleFen > maxSingleFen) { if (singleFen > maxSingleFen) {
maxSingleFen = singleFen; maxSingleFen = singleFen;
} }
@ -141,9 +139,7 @@ public class PddGoodsAddParamBuilder {
sku.put("sku_properties", skuProps); sku.put("sku_properties", skuProps);
skuList.add(sku); skuList.add(sku);
} }
if (marketFen <= maxSingleFen) { long marketFen = resolvePddMarketPriceFen(maxSuggestedFen, maxSingleFen);
marketFen = maxSingleFen + 100;
}
root.put("market_price", marketFen); root.put("market_price", marketFen);
root.put("sku_list", skuList); root.put("sku_list", skuList);
root.put("goods_properties", new JSONArray()); root.put("goods_properties", new JSONArray());
@ -188,6 +184,7 @@ public class PddGoodsAddParamBuilder {
root.put("is_refundable", props.isDefaultIsRefundable()); root.put("is_refundable", props.isDefaultIsRefundable());
root.put("second_hand", props.isDefaultSecondHand()); root.put("second_hand", props.isDefaultSecondHand());
root.put("shipment_limit_second", resolveShipmentSeconds(req, props)); root.put("shipment_limit_second", resolveShipmentSeconds(req, props));
root.put("two_pieces_discount", props.getTwoPiecesDiscount());
root.put("is_sku", false); root.put("is_sku", false);
List<String> carousel = resolveCarousel(goods, req); List<String> carousel = resolveCarousel(goods, req);
@ -202,18 +199,11 @@ public class PddGoodsAddParamBuilder {
} }
root.put("detail_gallery", detail); root.put("detail_gallery", detail);
long groupFen = yuanToFen(firstNonNull(row.getGroupPrice(), row.getRetailPrice())); long[] gs = groupAndSingleFenFromSuggestedRetail(row.getRetailPrice(), props, row.getOuterErpSkuId());
long singleFen = yuanToFen(firstNonNull(row.getRetailPrice(), row.getGroupPrice())); long groupFen = gs[0];
if (groupFen <= 0) { long singleFen = gs[1];
throw new IllegalStateException("SKU 团购价/拼团价无效(换算为 0 分outSkuId=" + row.getOuterErpSkuId()); long maxSuggestedFen = maxSuggestedRetailFen(skuRows);
} long marketFen = resolvePddMarketPriceFen(maxSuggestedFen, singleFen);
if (singleFen <= groupFen) {
singleFen = groupFen + 100;
}
long marketFen = resolveMarketPriceFen(req, skuRows);
if (marketFen <= singleFen) {
marketFen = singleFen + 100;
}
if (StringUtils.hasText(goods.getOuterErpGoodsId())) { if (StringUtils.hasText(goods.getOuterErpGoodsId())) {
root.put("out_goods_id", goods.getOuterErpGoodsId()); root.put("out_goods_id", goods.getOuterErpGoodsId());
@ -354,22 +344,59 @@ public class PddGoodsAddParamBuilder {
} }
} }
private static long resolveMarketPriceFen(ExternalGoodsUpsertRequest req, List<OGoodsSku> skuRows) { /**
if (req.getMarketPrice() != null) { * 定价market_price默认取各 SKU 建议零售价之最大若低于单买价则抬到单买价+1 分以满足常见校验
return yuanToFen(req.getMarketPrice()); */
private static long resolvePddMarketPriceFen(long maxSuggestedRetailFen, long maxSingleBuyFen) {
if (maxSuggestedRetailFen <= 0) {
throw new IllegalStateException("无法生成 market_price各 SKU 建议零售价retailPrice无效");
} }
BigDecimal maxSingle = BigDecimal.ZERO; long m = maxSuggestedRetailFen;
if (m <= maxSingleBuyFen) {
return maxSingleBuyFen + 1;
}
return m;
}
/** 各 SKU 建议零售价(元)换算为分后的最大值 */
private static long maxSuggestedRetailFen(List<OGoodsSku> skuRows) {
long max = 0L;
for (OGoodsSku s : skuRows) { for (OGoodsSku s : skuRows) {
BigDecimal p = firstNonNull(s.getRetailPrice(), s.getGroupPrice()); if (s == null) {
if (p != null && p.compareTo(maxSingle) > 0) { continue;
maxSingle = p; }
long fen = yuanToFen(s.getRetailPrice());
if (fen > max) {
max = fen;
} }
} }
if (maxSingle.compareTo(BigDecimal.ZERO) <= 0) { return max;
throw new IllegalStateException("marketPrice 与各 SKU 价格均为空,无法生成参考价");
} }
// 参考价略高于单买价
return yuanToFen(maxSingle) + 100; /**
* 拼单价= 建议零售价 ×groupBuyDiscountPercent/100单买价= 拼单价 + singleBuyAddFen HALF_UP
*/
private static long[] groupAndSingleFenFromSuggestedRetail(BigDecimal suggestedRetailYuan, ExternalPddProperties props,
String outSkuIdForError) {
if (suggestedRetailYuan == null || suggestedRetailYuan.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalStateException("SKU 建议零售价retailPrice无效无法按配置折算拼多多价格outSkuId=" + outSkuIdForError);
}
int pct = props.getGroupBuyDiscountPercent();
if (pct <= 0 || pct > 100) {
throw new IllegalStateException("external.pdd.group-buy-discount-percent 须为 1100当前=" + pct);
}
BigDecimal groupYuan = suggestedRetailYuan.multiply(BigDecimal.valueOf(pct))
.divide(BigDecimal.valueOf(100), 4, RoundingMode.HALF_UP);
long groupFen = groupYuan.multiply(BigDecimal.valueOf(100)).setScale(0, RoundingMode.HALF_UP).longValue();
if (groupFen <= 0) {
throw new IllegalStateException("折算后拼单价为 0 分请检查建议零售价与折扣outSkuId=" + outSkuIdForError);
}
long add = props.getSingleBuyAddFen();
if (add <= 0) {
throw new IllegalStateException("external.pdd.single-buy-add-fen 须为正整数(分),当前=" + add);
}
long singleFen = groupFen + add;
return new long[]{groupFen, singleFen};
} }
private static long yuanToFen(BigDecimal yuan) { private static long yuanToFen(BigDecimal yuan) {
@ -383,8 +410,4 @@ public class PddGoodsAddParamBuilder {
private static String fenAsSkuPriceString(long fen) { private static String fenAsSkuPriceString(long fen) {
return Long.toString(fen); return Long.toString(fen);
} }
private static BigDecimal firstNonNull(BigDecimal a, BigDecimal b) {
return a != null ? a : b;
}
} }