商品服务——SPU与SKU的管理&属性分组

一、基本概念(非常重要的电商概念)

1、SPU和SKU的初步理解:

SPU仿佛是Java中的类,SKU像是Java中具体的对象

  • SPU = Standard Product Unit (标准产品单位)

SPU是商品信息聚合的最小单位,是一组可复用、易检索的标准化信息的集合,该集合描述了一个产品的特性。通俗点讲,属性值、特性相同的商品就可以称为一个SPU。

——例如:iphone4就是一个SPU,与商家,与颜色、款式、套餐都无关

  • SKU=stock keeping unit(库存量单位)

SKU即库存进出计量的单位, 可以是以件、盒、托盘等为单位。SKU是物理上不可分割的最小存货单元。在使用时要根据不同业态,不同管理模式来处理。在服装、鞋类商品中使用最多最普遍。

SKU是对于大型连锁超市的DC(配送中心)物流管理的一个必要的方法,现在已经被引申为产品统一编号的简称,每一种具体产品均对应有唯一的SKU号,这样就不会弄错

——例如:纺织品中一个SKU通常表示:规格、颜色、款式。

2、基本属性(规格参数)与销售属性

说到SPU和SKU,就会引申出另外两个概念:规格参数 与 销售属性

每个产品分类下(三级分类树)的商品共享规格参数与销售属性,只是有些商品不一定要用这个分类下的全部属性:

  • 属性是以三级分类组织起来的;

  • 规格参数中有些是可以提供检索的;

  • 规格参数也是基本属性,它们拥有自己的分组;

  • 属性的分组也是以三级分类组织起来的;

  • 属性名确定的,但是值是每个具体商品不同自己决定的;

这些讲得太抽象,我们就以京东的截图来说话:选择一个具体的商品页面:https://item.jd.com/100000177740.html#crumb-wrap

基本属性(规格参数,商品介绍等):

39.jpg

销售属性:

40.jpg

总结一下:

  • 每个SPU拥有自己的基本属性(规格参数、商品介绍等);

  • 每个SKU对应自己的一个SKU编号,对应着自己的销售属性(颜色、内存等),直接决定了销售价格、库存等销售信息;

  • 一个SPU下面的所有SKU共享SPU的基本属性(规格参数、商品介绍等);


二、数据库表设计:

基于上面的概念介绍,我们的数据库设计如下:

41.jpg

这块的表设计很复杂,需要好好地理解一番;

主要分为3块,SPU的管理、SKU的管理,属性的管理;

其中属性是可以分组的,属性分组的入口在产品的三级分类tree目录;

出口则是具体的属性;但是由于是N:N的关系,所以需要一个中间关系表attr_attrgroup_relation表;

attr属性表为中间的核心表,但是其中只定义了属性的名称,却没有值,值是在具体的product_attr_value和sku_sale_attr_value表中保存;

我简单画了一个关系图(很多其他辅助字段是没有画出来的):

SPU_SKU.jpg


三、为具体的三级分类添加对应的属性分组管理

有了上面设计的数据库表结构和关系图,我们很容易就能想到,就是“根据之前完成的3级分类目录树,管理对应的属性分组信息”;

页面效果将被设计为“左右结构”——左边为Tree树,点击后右侧出现对应的属性分组信息;

1、因为之前已经演示过如何创建目录和菜单了,所以我们就快速地创建一下所需菜单即可:

创建菜单的SQL脚本如下:

CREATE DATABASE /*!32312 IF NOT EXISTS*/`zidanmall_admin` /*!40100 DEFAULT CHARACTER SET utf8mb4 */;

USE `zidanmall_admin`;

DROP TABLE IF EXISTS `sys_menu`;

CREATE TABLE `sys_menu` (
  `menu_id` bigint(20) NOT NULL AUTO_INCREMENT,
  `parent_id` bigint(20) DEFAULT NULL COMMENT '父菜单ID,一级菜单为0',
  `name` varchar(50) DEFAULT NULL COMMENT '菜单名称',
  `url` varchar(200) DEFAULT NULL COMMENT '菜单URL',
  `perms` varchar(500) DEFAULT NULL COMMENT '授权(多个用逗号分隔,如:user:list,user:create)',
  `type` int(11) DEFAULT NULL COMMENT '类型   0:目录   1:菜单   2:按钮',
  `icon` varchar(50) DEFAULT NULL COMMENT '菜单图标',
  `order_num` int(11) DEFAULT NULL COMMENT '排序',
  PRIMARY KEY (`menu_id`)
) ENGINE=InnoDB AUTO_INCREMENT=76 DEFAULT CHARSET=utf8mb4 COMMENT='菜单管理';

/*Data for the table `sys_menu` */

insert  into `sys_menu`(`menu_id`,`parent_id`,`name`,`url`,`perms`,`type`,`icon`,`order_num`) values (1,0,'系统管理',NULL,NULL,0,'system',0),(2,1,'管理员列表','sys/user',NULL,1,'admin',1),(3,1,'角色管理','sys/role',NULL,1,'role',2),(4,1,'菜单管理','sys/menu',NULL,1,'menu',3),(5,1,'SQL监控','http://localhost:8080/renren-fast/druid/sql.html',NULL,1,'sql',4),(6,1,'定时任务','job/schedule',NULL,1,'job',5),(7,6,'查看',NULL,'sys:schedule:list,sys:schedule:info',2,NULL,0),(8,6,'新增',NULL,'sys:schedule:save',2,NULL,0),(9,6,'修改',NULL,'sys:schedule:update',2,NULL,0),(10,6,'删除',NULL,'sys:schedule:delete',2,NULL,0),(11,6,'暂停',NULL,'sys:schedule:pause',2,NULL,0),(12,6,'恢复',NULL,'sys:schedule:resume',2,NULL,0),(13,6,'立即执行',NULL,'sys:schedule:run',2,NULL,0),(14,6,'日志列表',NULL,'sys:schedule:log',2,NULL,0),(15,2,'查看',NULL,'sys:user:list,sys:user:info',2,NULL,0),(16,2,'新增',NULL,'sys:user:save,sys:role:select',2,NULL,0),(17,2,'修改',NULL,'sys:user:update,sys:role:select',2,NULL,0),(18,2,'删除',NULL,'sys:user:delete',2,NULL,0),(19,3,'查看',NULL,'sys:role:list,sys:role:info',2,NULL,0),(20,3,'新增',NULL,'sys:role:save,sys:menu:list',2,NULL,0),(21,3,'修改',NULL,'sys:role:update,sys:menu:list',2,NULL,0),(22,3,'删除',NULL,'sys:role:delete',2,NULL,0),(23,4,'查看',NULL,'sys:menu:list,sys:menu:info',2,NULL,0),(24,4,'新增',NULL,'sys:menu:save,sys:menu:select',2,NULL,0),(25,4,'修改',NULL,'sys:menu:update,sys:menu:select',2,NULL,0),(26,4,'删除',NULL,'sys:menu:delete',2,NULL,0),(27,1,'参数管理','sys/config','sys:config:list,sys:config:info,sys:config:save,sys:config:update,sys:config:delete',1,'config',6),(29,1,'系统日志','sys/log','sys:log:list',1,'log',7),(30,1,'文件上传','oss/oss','sys:oss:all',1,'oss',6),(31,0,'商品系统','','',0,'editor',0),(32,31,'分类维护','product/category','',1,'menu',0),(34,31,'品牌管理','product/brand','',1,'editor',0),(37,31,'平台属性','','',0,'system',0),(38,37,'属性分组','product/attrgroup','',1,'tubiao',0),(39,37,'规格参数','product/baseattr','',1,'log',0),(40,37,'销售属性','product/saleattr','',1,'zonghe',0),(41,31,'商品维护','product/spu','',0,'zonghe',0),(42,0,'优惠营销','','',0,'mudedi',0),(43,0,'库存系统','','',0,'shouye',0),(44,0,'订单系统','','',0,'config',0),(45,0,'用户系统','','',0,'admin',0),(46,0,'内容管理','','',0,'sousuo',0),(47,42,'优惠券管理','coupon/coupon','',1,'zhedie',0),(48,42,'发放记录','coupon/history','',1,'sql',0),(49,42,'专题活动','coupon/subject','',1,'tixing',0),(50,42,'秒杀活动','coupon/seckill','',1,'daohang',0),(51,42,'积分维护','coupon/bounds','',1,'geren',0),(52,42,'满减折扣','coupon/full','',1,'shoucang',0),(53,43,'仓库维护','ware/wareinfo','',1,'shouye',0),(54,43,'库存工作单','ware/task','',1,'log',0),(55,43,'商品库存','ware/sku','',1,'jiesuo',0),(56,44,'订单查询','order/order','',1,'zhedie',0),(57,44,'退货单处理','order/return','',1,'shanchu',0),(58,44,'等级规则','order/settings','',1,'system',0),(59,44,'支付流水查询','order/payment','',1,'job',0),(60,44,'退款流水查询','order/refund','',1,'mudedi',0),(61,45,'会员列表','member/member','',1,'geren',0),(62,45,'会员等级','member/level','',1,'tubiao',0),(63,45,'积分变化','member/growth','',1,'bianji',0),(64,45,'统计信息','member/statistics','',1,'sql',0),(65,46,'首页推荐','content/index','',1,'shouye',0),(66,46,'分类热门','content/category','',1,'zhedie',0),(67,46,'评论管理','content/comments','',1,'pinglun',0),(68,41,'spu管理','product/spu','',1,'config',0),(69,41,'发布商品','product/spuadd','',1,'bianji',0),(70,43,'采购单维护','','',0,'tubiao',0),(71,70,'采购需求','ware/purchaseitem','',1,'editor',0),(72,70,'采购单','ware/purchase','',1,'menu',0),(73,41,'商品管理','product/manager','',1,'zonghe',0),(74,42,'会员价格','coupon/memberprice','',1,'admin',0),(75,42,'每日秒杀','coupon/seckillsession','',1,'job',0);

导入成功后,页面菜单效果如下:

42.jpg

2、属性分组,我们使用“左右布局”的栅格结构

<el-row :gutter="20">
  <el-col :span="6"><div class="grid-content bg-purple"></div></el-col>
  <el-col :span="18"><div class="grid-content bg-purple"></div></el-col>
</el-row>

效果如下:

43.jpg

3、将之前的3级分类树的查询列表封装为一个组件component:

3.1、封装的树组件只有查询功能,其他增删改复杂操作都不需要:

3.2、封装的组件的使用:

  • 3.2.1、导入组件:import Category from "../common/category";

  • 3.2.2、在components中定义使用组件:components: { Category, AddOrUpdate };

  • 3.2.3、在页面元素中使用标签:<category></category> 直接使用;

4、右侧属性分组部分,我们使用之前逆向工程生成的vue文件快速完成;

44.jpg

5、达到上面的“左右布局”栅格结构后,我们需要完成一个重要的工作

点击左侧的树节点,右侧的属性分组列表会动态改变,查出对应的三级分类下的属性分组列表

这里就会使用到Vue中的一个重要技术点:父子组件传值——子组件给父组件传值——通过事件机制

即树节点被点击后,引用它的父组件attrgroup.vue,能感知到,并执行相应的操作;

5.1、当子组件被点击后,给父组件发送一个事件,携带上数据——类似于父子组件间的冒泡事件;

在子组件的 <el-tree> 组件上绑定自己的点击事件;

<el-tree :data="menus" :props="defaultProps" node-key="catId" ref="menuTree" @node-click="nodeclick"></el-tree>

5.2、通过 this.$emit("事件名", 传递的参数) 方法将事件传递给父组件;

nodeclick(data, node, component){
        console.log("子组件节点被点击", data, node, component);
        //向父组件发送事件this.$emit("事件名", 传递的参数)
        this.$emit("tree-node-click", data, node, component);
    }

5.3、父组件在 <category> 中监听子组件的“tree-node-click”事件,并调用自己的方法:treeNodeClick()

<el-col :span="6">
      <!-- 父组件监听子组件的tree-node-click事件,并执行自己的方法 -->
      <category @tree-node-click="treeNodeClick"></category>
    </el-col>

5.4、treeNodeClick()方法体如下:

//感知子组件的tree节点被点击后调用的方法:
      treeNodeClick(data, node, component){
        console.log("attrgroup父组件感知到子组件树节点被点击", data, node, component);
      },

5.5、测试结果如下:

45.jpg

子组件向父组件传值完成;


四、获取具体3级分类对应的属性分组列表:

1、Java后端接口,service层实现:

/**
 *
 * @param params 其中除了分页参数外,还可能有查询字段key,key可能是groupId,也可能是名称的模糊查询
 * @param catelogId
 * @return
 */
@Override
public PageUtils queryPage(Map<String, Object> params, Long catelogId) {
    if (catelogId == 0){
        IPage<AttrGroupEntity> page = this.page(
                new Query<AttrGroupEntity>().getPage(params),
                new QueryWrapper<AttrGroupEntity>()
        );
        return new PageUtils(page);
    }else {
        String key = (String) params.get("key");
        //select * from pms_attr_group where catelog_id = ? and (attr_group_id = ? or attr_group_name like %key%);
        QueryWrapper<AttrGroupEntity> wrapper = new QueryWrapper<AttrGroupEntity>().eq("catelog_id", catelogId);
        if (StringUtils.isNoneBlank(key)){
            wrapper.and(obj -> obj.eq("attr_group_id", key).or().like("attr_group_name", key));
        }
        IPage<AttrGroupEntity> page = this.page(new Query<AttrGroupEntity>().getPage(params), wrapper);
        return new PageUtils(page);
    }
}

2、前端vue实现:

 //感知子组件的tree节点被点击后调用的方法:
      treeNodeClick(data, node, component){
        console.log("attrgroup父组件感知到子组件树节点被点击", data, node, component);
        if(node.level == 3){
          this.catId = data.catId;
          this.getDataList();
        }
      },
      // 获取数据列表
      getDataList () {
        this.dataListLoading = true
        this.$http({
          url: this.$http.adornUrl(`/product/attrgroup/list/${this.catId}`),
          method: 'get',
          params: this.$http.adornParams({
            'page': this.pageIndex,
            'limit': this.pageSize,
            'key': this.dataForm.key
          })
        }).then(({data}) => {
          if (data && data.code === 0) {
            this.dataList = data.page.list
            this.totalPage = data.page.totalCount
          } else {
            this.dataList = []
            this.totalPage = 0
          }
          this.dataListLoading = false
        })
      }

3、测试结果:

46.jpg


五、新增属性分组时,所属分类实现级联选择器:

1、级联选择器我们选择 element-ui 的级联选择器:Cascader 级联选择器

只需为 Cascader 的 options属性 指定 选项数组 即可渲染出一个级联选择器。通过props.expandTrigger可以定义展开子级菜单的触发方式。

<el-form-item label="所属分类id" prop="catelogId">
        <!-- <el-input v-model="dataForm.catelogId" placeholder="所属分类id"></el-input> -->
        <el-cascader v-model="dataForm.catelogIds" :options="categorys" :props="props"></el-cascader>
      </el-form-item>

2、data内容:

data() {
    return {
      props: {
        value:"catId",
        label:"name",
        children:"children"
      },
      visible: false,
      categorys: [],
      dataForm: {
        attrGroupId: 0,
        attrGroupName: "",
        sort: "",
        descript: "",
        icon: "",
        catelogIds: [],   //级联选择器绑定值必须为数组[]
        catelogId: 0      //实际提交到后台的为数组最后一个值,即最底层
      }
}

3、相关方法:

methods: {
    getCategorys() {
      this.$http({
        url: this.$http.adornUrl("/product/category/list/tree"),
        method: "get"
      }).then(({ data }) => {
        console.log("成功获取到菜单数据...", data.data);
        this.categorys = data.data;
      });
    },
    dataFormSubmit() {
      this.$refs["dataForm"].validate(valid => {
        if (valid) {
          this.$http({
            url: this.$http.adornUrl(
              `/product/attrgroup/${
                !this.dataForm.attrGroupId ? "save" : "update"
              }`
            ),
            method: "post",
            data: this.$http.adornData({
              attrGroupId: this.dataForm.attrGroupId || undefined,
              attrGroupName: this.dataForm.attrGroupName,
              sort: this.dataForm.sort,
              descript: this.dataForm.descript,
              icon: this.dataForm.icon,
              catelogId: this.dataForm.catelogIds[this.dataForm.catelogIds.length-1]
            })
          }).then();
        }
      });
    }
}
created() {
    this.getCategorys();
  }

4、测试效果:

47.jpg

5、修改或查看详情时,我们需要让级联选择器回显我们的选择:

因为我们保存到数据库的所属分类知识最后一层“手机”的catId,但是级联选择器需要绑定的是一个数组:v-model = "dataForm.catelogIds";

这就需要我们的后端在获取详细信息的时候,将catelogIds参数一并返回回来:

在AttrGroupEntity中增加数据库中没有的属性:

/**
 * 所属分类的完整路径
 */
@TableField(exist = false)
private Long[] catelogPath;

Java中核心接口的Service层实现:

/**
 * 根据catelogId查询此分类的完整路径:Long[]数组
 * @param catelogId
 * @return
 */
@Override
public Long[] findCatelogPath(Long catelogId) {
    List<Long> paths = new ArrayList<>();
    findParent(paths, catelogId);
    Collections.reverse(paths);
    return paths.toArray(new Long[paths.size()]);
}

private void findParent(List<Long> paths, Long catelogId){
    paths.add(catelogId);
    CategoryEntity byId = this.getById(catelogId);
    if (byId.getParentCid() != 0){
        findParent(paths, byId.getParentCid());
    }
}

6、在vue中init()的then()回调中直接绑定结果即可:

//级联选择器需要dataForm.catelogs的完整路径,数组[]
this.dataForm.catelogIds = data.attrGroup.catelogPath;

测试效果:

48.jpg

7、优化:开启级联选择器的可搜索功能:

根据官方文档:

filterable 赋值为true 即可打开搜索功能,默认会匹配节点的label或所有父节点的label(由show-all-levels决定)中包含输入值的选项。你也可以用filter-method自定义搜索逻辑,接受一个函数,第一个参数是节点node,第二个参数是搜索关键词keyword,通过返回布尔值表示是否命中。

<el-cascader filterable v-model="dataForm.catelogIds" :options="categorys" :props="props"></el-cascader>

49.jpg


六、总结:

到这里,我们的属性分组功能就全部完成了;

前端vue的工作中常用的技术点也差不多覆盖了,后面实施过程中,就不会再去一步步地记录前端Vue的代码编写过程了;

而是注重后台的接口逻辑及架构设计;

所有的后端所需接口,我参开API文档:

https://easydoc.xyz/s/78237135

jiguiquan@163.com

文章作者信息...

留下你的评论

*评论支持代码高亮<pre class="prettyprint linenums">代码</pre>

相关推荐