业务场景:小程序sku算法(商品多规格选择)

3,979 阅读9分钟

话不多说,先上图,就知道要做什么了

在掘金上或者各种网址上有很多关于“商品多规格选择”的问题,例如有全排列组合的,我写该需求的时候,也在网上找了很多解答,思路都非常棒,但是并没有紧贴业务场景。(有些紧贴业务场景,但是不符合我所需要的业务场景)。

真正的业务场景是,首次渲染,就需要把所有支持的规格都呈现出来,不能选择的规格置灰,我们要根据用户每一次选择的规格,找出剩下可选的规格和不可选的规格,就是如下效果:

什么是sku

经常会听到一个词“SKU”,“SKU”是什么呢?通俗来说就是身份证,产品的身份证。我们每一个人,都有一个身份证号码,同样的,每一个产品在电商系统中也有一个身份证号码,那就是SKU。 英文全称为 stock keeping unit, 简称SKU,定义为保存库存控制的最小可用单位,例如纺织品中一个SKU通常表示规格,颜色,款式)。 STOCK KEEP UNIT.这是客户拿到商品放到仓库后给商品编号,归类的一种方法

SKU中包含的信息

1. 品项

可以结合上面关于单品、SKU和品种的解释来理解。也就是只要属性有不同,那么就是不同的品项(SKU)。可以说这是SKU看作是一种产品的角度来分析理解的。属性有很多种,也就是说同样的产品只要在人们对其进行保存、管理、销售、服务上有不同的方式,那么它(SKU)就不再是相同的了。

2. 编码

这个概念是基于信息系统和货物编码管理来说的,像“品项”中介绍的那样,不同的品项(SKU)就有不同的编码。这样子,我们才可以依照不同的SKU数据来分析库存、销售状况。但是这里的产品如“品项”所说,并非是一个泛泛的产品的概念,而是很精确的产品概念。

3.单位单位

基本上就是基于管理来说的吧,这个名字上是数字化管理方式的产物。但是这里的单位和我们平时的“单位”有什么区别呢?看看产品的包装单位的不同,SKU就不同——你就知道了。

产品SKU在电商仓库中的作用

1.从货品角度看

SKU是指单独一种商品,其货品属性已经被确定。也就是说同样的货品只要在人们对其进行保存、管理、销售、服务上有不同的方式,那么就需要被定义为不同的SKU

2.从业务管理的角度看

SKU还含有货品包装单位的信息。例如:SKU#123是指330ml瓶装黑啤(以瓶为单位);SKU#456 是指330ml瓶装黑啤(以提为单位,6瓶为1提);SKU#789 是指330ml瓶装黑啤(以箱为单位,24瓶为1箱)。由于计量单位(包装单位)不同,为业务管理需要,应划归于不同的SKU,当然可以有单位转换的算法协助转换SKU。

3.从信息系统和货物编码角度看

SKU只是一个编码。不同的一种商品(商品名称)就有不同的编码(SKU#)。而这个编码与被定义的商品做了一一对应的关联,这样我们才可以依照不同SKU的数据来记录和分析库存和销售情况。

一般讲SKU是在某一体系(例如:公司或工厂)内部自定义和使用的。跨体系需要重新定义或做SKU转换。

业务场景

笔者没接触过商品多规格业务,以及自己对于小程序这方面,接触的也不多,代码质量不足,不要介意。

使用技术栈

主要是针对 taro+react+hook 版本的小程序,由于以前的开发都是用js+class,所以hook用的比较少(虽然自己也很想用hook)

下面重点解析sku选择器

主要代码

1.sku的格式
spu的所有sku规格形式 (spu_spec_all_values )

表明一共有两个规格,name为该规格的名字,values是该规格下的种类

全部目前已上架的sku商品规格 (sku_setting_specs )

进入商品详情页默认选中的sku规格 (specification )

2.代码

我这里主要呈现payModal 的核心代码,商品详情页的代码就不呈现了

主要思路: 进来商品详情页面默认选中规格(如果没有该sku规格,或者sku的库存为0,则置灰) -> 切换可点击的不同规格 -> 获取到选择完的sku信息,加入购物车或者立即购买,或者查看该sku的详情

因为小程序用了mobx,class添加了observer()包裹,所以获取的接口数据为object或者array 都需要用mobx内置toJS方法解析出来

  • 核心代码
  
  const [skuInfo, setSkuInfo] = useState({});// 存储sku规格的信息
  const [skuHold, setSkuHold] = useState(0); // sku的库存
  
  useEffect(() => {
    if (toJS(specification) && toJS(specification).length) {
      setActiveSizeValue(formatSpecification(toJS(specification)));
      setDrawOptions();
    }
    setSkuHold(stock);
    setSkuInfo({
      skuStock: stock, // 库存
      skuImage: sku_img, // sku图片
      skuOriPrice: ori_price, // 旧价格
      skuIsShowLinePrice: is_show_line_price, // 是否展示划线
      skuShowPrice: show_price // 现在sku的价格
    });

  }, [spu_spec_all_values, specification, sku_setting_specs, ori_price, is_show_line_price])


/**
  * 核心代码
  * @param selectedSpec 已选中的数组
  * @param currentSpecName 当前点击的规格的名称
  * @param value 默认已选的规格(只会刚进来的时候有值)
  * @param typeClick 是否点击选择了
  */
  const skuCore = (selectedSpec, currentSpecName, value, typeClick) => {
    const spec1 = typeClick !== 'click' && value ? value : spec;
    const skus = toJS(sku_setting_specs);
    Object.keys(spec1).forEach((sk) => {
      if (sk !== currentSpecName) {
        // 找出该规格中选中的值
        const currentSpecSelectedValue = spec1[Object.keys(spec1).find((_sk) => sk === _sk) || ''].find((sv) => sv.select)
        spec1[sk].forEach((sv) => {
          // 判断当前的规格的值是否是选中的,如果是选中的 就不要判断是否可以点击直接跳过循环
          if (!sv.select) {
            const _ssTemp = [...selectedSpec]
            // 如果当前规格有选中的值
            if (!!currentSpecSelectedValue) {
              const sIndex = _ssTemp.findIndex((_sv) => _sv === `${sk}:${currentSpecSelectedValue.value}`)
              _ssTemp.splice(sIndex, 1)
            }
            _ssTemp.push(`${sk}:${sv.value}`)
            const _tmpPath = []
            // 找到包含该路径的全部sku
            skus.forEach((sku) => {
              // 找出skus里面包含目前所选中的规格的路径的数组的数量
              const querSkus = _ssTemp.filter((_sst) => {
                const querySpec = objTransformArr(sku.specs).some(p => {
                  return p === _sst
                })
                return querySpec
              })
              const i = querSkus.length
              if (i === _ssTemp.length) {
                _tmpPath.push(sku) // 把包含该路径的sku全部放到一个数组里
              }
            })
            const hasHoldPath = _tmpPath.find((p) => p.stock) // 判断里面是要有个sku不为0 则可点击
            let isNotEmpty = hasHoldPath ? hasHoldPath.stock : 0
            sv.disable = !isNotEmpty
          }
        })
      }
    })

    // 判断是否可以添加进购物车,比如属性是否有选,库存情况等
    if (judgeCanAdd(skus)) {
      const sku_info = getSkuInfoByKey(spec1) || {}; // 获取sku信息
      const hold = sku_info.stock || 0; // 获取sku的库存
      setSkuHold(hold);
      setSkuInfo({
        skuStock: sku_info.stock,
        skuImage: sku_info.image,
        skuOriPrice: sku_info.ori_price,
        skuIsShowLinePrice: sku_info.is_show_line_price,
        skuShowPrice: sku_info.show_price
      });
    }
  }
  • 通过skus初始化 各个规格

// 初始化已选的规格 格式 ["尺寸:S", "毫升:100ml"]
const formatSpecification = (specification) => {
  if (!Array.isArray(specification)) {
    return []
  }
  return specification.map(i =>
    `${i.key}:${i.val}`
  )
}

// activeSizeValue 格式 ["尺寸:S", "毫升:100ml"]
const [activeSizeValue, setActiveSizeValue] = useState([]);

// spec 格式 {尺寸:{value: "S", disable: false, select: true}}
const [spec, setSpec] = useState({});


 useEffect(() => {
 	// 如果存在多规格 则执行skus初始化 各个规格
    if (toJS(specification) && toJS(specification).length) {
      // 把默认已选的规格初始化给 activeSizeValue
      setActiveSizeValue(formatSpecification(toJS(specification)));
      setDrawOptions();
    }
  }, [specification])

/**
   * 通过skus初始化 各个规格
   */
  const setDrawOptions = () => {
  	// spu的所有sku规格形式
    const skus = toJS(spu_spec_all_values) || [];
    let _tags = {};//临时存储
    const _tempTagsStrArray = {}; //临时存储
    const defaultValue = toJS(specification) || []; // 默认规格
    // 所有的规格name 
    const nameKeys = defaultValue.map(item => item.key);
    
    skus.forEach(s => {
      s.values.forEach(p => {
      	// 不存在该key时,初始化对象
        if (!_tags[s.name]) {
          _tags[s.name] = []
          _tempTagsStrArray[s.name] = [];
        }
        // 
        if (!_tempTagsStrArray[s.name].includes(p)) {
          _tempTagsStrArray[s.name].push(p);
          // 为了判断规格 是否已选
          const result = defaultValue.some(function (item) {
            if (item.val === p && item.key === s.name) {
              return true;
            }
          })
          _tags[s.name].push({
            value: p,
            disable: true,
            select: result ? true : false
          })
        }
      })
    });
    
    setSpecDisable(_tags)
  }
  • 设置规格是否可选
// 为了每次第一次渲染都渲染spu下的默认sku规格
const [num, setNum] = useState(0)


  /** 
  * 用于初始化规格和规格都没选中的时候 设置 规格是否可以点击,
  * 该路径上如果跟该属性的组合没有则该属性不能点击 
  */
  const setSpecDisable = (tags, isReset) => {
    const skus = toJS(sku_setting_specs) || [];
    const defaultValue = toJS(specification) || [];
    
    Object.keys(tags).forEach((sk) => {
      tags[sk].forEach((sv) => {
        const currentSpec = `${sk}:${sv.value}`;
        // 找到含有该规格的路径下 库存不为0的 sku
        const querySku = skus.find((sku) => {
          for (let key in sku.specs) {
            const queryProperty = `${key}:${sku.specs[key]}` === currentSpec;
            if (queryProperty) {
              return queryProperty && sku.stock
            }
          }
        })
        // 如果找到 对应该属性的路径 sku有不为0 的则可选
        sv.disable = !querySku
      })
    })

    setSpec({ ...tags })

    // activeSizeValue不为空
    if (!isReset) {
      // 这是因为mobx的问题,导致activeSizeValue初始化有问题,临时存储,后期优化
      const a = formatSpecification(defaultValue);
      // 第一次进来渲染接口的数据,以后都是点击的数据
      // num > (defaultValue.length - 1) 默认规格的长度
      const b = activeSizeValue.length || num > (defaultValue.length - 1) ? activeSizeValue : a;
      defaultValue.forEach(item => {
        if (b.length) {
          skuCore(b, item.key, tags);
          setNum(num + 1)
        }
      })
    }
  }
  • 规格点击事件
/** 
* k - 规格名称,如:尺寸
* currentSpectValue - 规格value 如:'s'
* 规格选项点击事件 
*/
  const onPressSpecOption = (k, currentSpectValue) => {
    let isCancel = false;
    // 找到在全部属性spec中对应的属性
    const currentSpects = spec[Object.keys(spec).find((sk) => sk === k) || ''] || [];

    // 上一个被选中的的属性
    const prevSelectedSpectValue = currentSpects.find((cspec) => cspec.select) || {}

    // 设置前一个被选中的值为未选中
    prevSelectedSpectValue.select = false;

    // 只有当当前点击的属性值不等于上一个点击的属性值时候设置为选中状态
    if (prevSelectedSpectValue === currentSpectValue) {
      isCancel = true
    } else {
      // 设置当前点击的状态为选中
      currentSpectValue.select = true
    }

    // 全部有选中的规格数组 ##可优化
    const selectedSpec = Object.keys(spec)
      .filter((sk) => spec[sk].find((sv) => sv.select))
      .reduce((prev, currentSpecKey) => {
        return [...prev, `${currentSpecKey}:${spec[currentSpecKey].find((__v) => __v.select).value}`]
      }, [])

    if (isCancel) {
      // 如果是取消且全部没选中
      if (!selectedSpec.length) {
        // 初始化是否可点
        setSpecDisable(spec, 'reset')
      }
    }
    // 如果规格中有选中的 则对整个规格就行 库存判断 是否可点
    if (selectedSpec.length) {
      skuCore(selectedSpec, k, '', 'click');
    }

	// 把选中的规格赋值给activeSizeValue
    setActiveSizeValue(selectedSpec);

    setSpec({ ...spec });
  }

  • 判断是否可以购买或加入购物车
// 判断是否可以加入购物车
const [canFlag, setCanFlag] = useState(false);

/** 判断是否可以购买或加入购物车,比如属性是否有选,库存情况等 */

  const judgeCanAdd = (skus = []) => {
    const sks = Object.keys(spec);
    // 已经选择的规格个数
    let s = sks.filter((sk) => spec[sk].some((sv) => sv.select)).length 
    // 比较已选的长度是否和需要选择的长度一致
    let _cf = s === sks.length
    if (!skus || !skus.length) {
      _cf = false
    }
    if (skus && skus.length === 1 && !Object.keys(skus[0].specs).length && skus[0].stock <= 0) {
      _cf = false
    }
    setCanFlag(_cf)
    return _cf
  }

  • 返回规格信息
/**
  * @param _spec 规格属性
  * 返回所有信息
  */
  const getSkuInfoByKey = (_spec) => {
    // 已选的规格:[{ name:规格名称, value:已选规格内容 }]
    const selectedSpec = {};

    Object.keys(_spec).forEach((k) => {
      const selectedValue = _spec[k].find((sv) => sv.select);
      if (selectedValue) {
        // 这块部分也可以在选择的时候直接处理
        selectedSpec[k] = selectedValue.value
      }
    })


    const skus = toJS(sku_setting_specs) || [];
    const querySku = skus.find((sku) => {

      // 对比两个数组找到 两个都不存在的sku 如果为0 则说明完全匹配就是该sku
      const diffSkus = isObjShallowEqual(selectedSpec, sku.specs)
      return diffSkus
    }) || {};

    return querySku;
  }

总结

因为代码一直修修改改,所有有很多冗余和质量不好的代码,后期会优化,敬请谅解(因为业务需求,时间太赶了,先凑合着把功能实现,后期再进去优化)。

如果你觉得这篇文章对你有用的话,请给个赞吧!!

也可以扫码进入小程序查看,扫扫下面的二维码👇