ECSHOP 商品最小起订量完整二开方案:min_number 字段、分阶梯按等级、配送方式联动与购物车二次校验

ECSHOP 默认没有起订量功能,包邮活动容易被买一件的低单价订单亏本。本文给出 ecs_goods 加 min_number 字段、admin/goods.php 表单处理、模板与 JS 校验、lib_order.php 加车与下单服务端二次校验的完整改造,并扩展到按会员等级分阶梯、按配送方式联动、与限购促销共存等高级场景。

更新 30 分钟阅读 1,958 阅读

ECSHOP 默认没有「商品最小起订量」功能。运营做包邮活动时常遇到这种困境:定个 50 元包邮,结果用户买一支 5 元的笔订单也包邮,倒贴运费 12 元。或者批发型店铺希望小客户买 10 件起订才划算,单卖一件不接。本文给出从数据库表结构、后台编辑界面、前台 JS 校验、加车流程拦截、与促销活动联动、配送规则配合这一整套「最小起订量」的二次开发方案,覆盖 ECSHOP 2.7.x 的完整改造,并扩展到分阶梯起订(不同会员等级不同最小数量)、配送方式起订(国际订单起订量更高)、按商品分类批量设置起订量等运营常见需求。

需求场景分析

三种典型起订量场景

  • 包邮门槛防漏:单件价格低于运费的商品,强制起订量让总价超过包邮成本。例如笔类单价 5 元,运费 12 元,设起订量 5 件。
  • 批发型店铺:散户用零售平台,B2B 客户走最小起订量。整箱、整托盘起订。
  • 定制商品:印刷、刺绣、3D 打印等需要开模或备料的商品,少于 N 件不接单(材料成本与利润摊不开)。

方案选型

有三种实现路径:

  1. 纯前端 JS 限制:改 goods.dwt 模板里的 input,加 onkeyup 校验。优点是改动小,缺点是用户禁用 JS 或直接构造 POST 请求绕过。
  2. 商品级别字段 min_number:给 ecs_goods 表加字段,每个商品独立设最小起订量,前后端联动。本文主推方案。
  3. 分类级别配置:给 ecs_category 表加字段,整个分类下的商品共享起订量。运营批量配置友好但灵活性差。

方案一:纯前端 JS 全站起订量(最简)

编辑 themes/default/goods.dwt,找到购买数量输入框:

<input name="number" type="text" id="number" value="1" size="4"
       onblur="changePrice()" style="border:1px solid #ccc;" />

替换为:

<input name="number" type="number" id="number" value="10" size="4" min="10"
       onblur="if(parseInt(this.value)<10){alert('购买数量不少于 10 件');this.value=10;}changePrice()"
       onkeyup="if(parseInt(this.value)>0 && parseInt(this.value)<10){this.style.borderColor='red';}else{this.style.borderColor='#ccc';}"
       style="border:1px solid #ccc;" />

这种方案 5 分钟搞定,但有四个明显短板:

  • 所有商品共享同一起订量,不灵活。
  • 禁用 JS 的浏览器(占比极少,但确实存在)能绕过。
  • 用户在购物车页面改数量时校验不生效。
  • API 直连下单的客户端(ECTouch 移动端)走另一套代码,校验失效。

方案二:商品级别 min_number 字段(推荐方案)

数据库加字段

在后台「数据库管理-SQL 查询」执行:

ALTER TABLE `ecs_goods`
ADD COLUMN `min_number` SMALLINT(5) UNSIGNED DEFAULT 0 NOT NULL
AFTER `warn_number`;

原文用 tinyint(3),最大值 255 偏小(批发场景可能起订 500 件起)。改成 smallint(5) 上限 65535 更稳。AFTER warn_number 让字段位置紧跟库存预警字段,逻辑相关。

商品列表查询带上 min_number

编辑 admin/includes/lib_goods.php,找到 goods_list 函数的 SELECT 语句,把 min_number 加入字段列表:

$sql = "SELECT goods_id, goods_name, goods_type, goods_sn, shop_price, " .
       "is_on_sale, is_best, is_new, is_hot, sort_order, goods_number, " .
       "warn_number, min_number, integral, " .
       "(promote_price > 0 AND promote_start_date <= '$today' AND promote_end_date >= '$today') AS is_promote " .
       "FROM " . $GLOBALS['ecs']->table('goods') . " AS g " .
       "WHERE is_delete='$is_delete' $where " .
       "ORDER BY $filter[sort_by] $filter[sort_order] " .
       "LIMIT " . $filter['start'] . ",$filter[page_size]";

admin/goods.php 处理表单提交

找到默认值数组(是 $_POST 的 fallback),添加 min_number:

// 在 'warn_number' => 1 后添加
'min_number'   => 0,

找到 $warn_number 解析行,下面添加:

$warn_number = isset($_POST['warn_number']) ? intval($_POST['warn_number']) : 0;
$min_number = isset($_POST['min_number']) ? intval($_POST['min_number']) : 0;
if ($min_number < 0) $min_number = 0;
if ($min_number > 65535) $min_number = 65535;

用 intval 强转防注入,数值范围校验避免错误数据。

INSERT/UPDATE SQL 加 min_number

原文给的 SQL 一大段需要替换,但本质就是把 min_number 加入字段列表与 VALUES 列表。简化版(关键改动用注释标出):

// INSERT
if ($is_insert) {
    $sql = "INSERT INTO " . $ecs->table('goods') . "
        (goods_name, goods_sn, cat_id, brand_id, shop_price, market_price,
         goods_number, warn_number, min_number, /* 新增 */
         is_best, is_new, is_hot, is_on_sale, goods_desc, add_time, last_update)
        VALUES
        ('{$_POST['goods_name']}', '{$goods_sn}', '{$catgory_id}', '{$brand_id}',
         '{$shop_price}', '{$market_price}',
         '{$goods_number}', '{$warn_number}', '{$min_number}', /* 新增 */
         '{$is_best}', '{$is_new}', '{$is_hot}', '{$is_on_sale}',
         '{$_POST['goods_desc']}', '" . gmtime() . "', '" . gmtime() . "')";
}

// UPDATE
else {
    $sql = "UPDATE " . $ecs->table('goods') . " SET
        goods_name = '{$_POST['goods_name']}',
        goods_sn = '{$goods_sn}',
        cat_id = '{$catgory_id}',
        brand_id = '{$brand_id}',
        shop_price = '{$shop_price}',
        market_price = '{$market_price}',
        goods_number = '{$goods_number}',
        warn_number = '{$warn_number}',
        min_number = '{$min_number}', /* 新增 */
        is_best = '{$is_best}',
        is_on_sale = '{$is_on_sale}',
        goods_desc = '{$_POST['goods_desc']}',
        last_update = '" . gmtime() . "'
        WHERE goods_id = '{$_REQUEST['goods_id']}' LIMIT 1";
}

原文用了字符串拼接的写法,存在 SQL 注入风险(goods_name 等字段直接进 SQL)。生产环境强烈建议改用 PDO 预编译,但 ECSHOP 没有原生支持,只能在 INSERT/UPDATE 之前用 addslashes 与 intval 兜底。

后台编辑模板加输入框

编辑 admin/templates/goods_info.htm,定位「shop_price」字段,在其下添加:

<tr>
    <td class="label">{$lang.lab_min_number}</td>
    <td>
        <input type="number" name="min_number" value="{$goods.min_number|default:0}"
               min="0" max="65535" size="20" />
        <span class="notice-span"
              {if $help_open}style="display:block"{else}style="display:none"{/if}
              id="minNumber">
            {$lang.notice_min_number}
        </span>
    </td>
</tr>

type="number" 让浏览器原生提供数字输入控件,min/max 属性限制输入范围。

语言包扩展

编辑 languages/zh_cn/admin/goods.php,文件末尾添加:

$_LANG['lab_min_number']    = '最小起订量:';
$_LANG['notice_min_number'] = '该商品最少购买数量。0 表示无限制;正整数表示用户至少要购买此数量才能下单。';

前台模板购买框默认值改为 min_number

编辑 themes/default/goods.dwt,找到:

<input name="number" type="text" id="number" value="1" size="4"
       onblur="changePrice()" style="border:1px solid #ccc;" />

改成:

<input name="number" type="number" id="number"
       value="{if $goods.min_number > 0}{$goods.min_number}{else}1{/if}"
       min="{if $goods.min_number > 0}{$goods.min_number}{else}1{/if}"
       size="4" onblur="changePrice()" style="border:1px solid #ccc;" />
{if $goods.min_number > 0}
<span style="color:#e74c3c;font-size:12px;">此商品最少购买 {$goods.min_number} 件</span>
{/if}

用户打开商品页时,input 默认值就是起订量,并且红色提示明示。

前台 JS 校验

同模板找到「立即购买」与「加入购物车」的 JS 函数,最常见的是 addToCart() 与 buyNow()。在它们调用之前加校验:

<script type="text/javascript">
function validateMinNumber() {
    var qty = parseInt(document.forms['ECS_FORMBUY'].elements['number'].value);
    {if $goods.min_number > 0}
    if (qty < {$goods.min_number}) {
        alert('此商品最少购买 {$goods.min_number} 件,已自动调整数量');
        document.forms['ECS_FORMBUY'].elements['number'].value = {$goods.min_number};
        changePrice();
        return false;
    }
    {/if}
    return true;
}
</script>

原本的 addToCart 与 buyNow 函数最前面加调用:

function addToCart(goods_id, parent) {
    if (!validateMinNumber()) return;
    // ... 原有逻辑
}

购物车与下单环节的二次校验

仅前台校验不够

用户可能:在购物车页直接改数量;用 ECTouch 移动端不走 PC JS;通过 API 直接下单。所以服务端必须做一次校验。

flow.php 加车前校验

编辑 includes/lib_order.php,找到 addto_cart 函数(添加到购物车的核心函数),在 INSERT 之前校验:

function addto_cart($goods_id, $num = 1, $spec = array(), $parent = 0)
{
    $goods_id = intval($goods_id);
    $num = intval($num);

    /* 取商品信息含 min_number */
    $goods = $GLOBALS['db']->getRow(
        "SELECT goods_name, goods_number, min_number, is_on_sale
         FROM " . $GLOBALS['ecs']->table('goods') . "
         WHERE goods_id = '$goods_id'"
    );

    if (empty($goods) || $goods['is_on_sale'] != 1) {
        $GLOBALS['err']->add('商品不存在或已下架');
        return false;
    }

    /* 起订量校验 */
    if ($goods['min_number'] > 0 && $num < $goods['min_number']) {
        $GLOBALS['err']->add(sprintf(
            '商品「%s」最少购买 %d 件,您选购了 %d 件',
            $goods['goods_name'],
            $goods['min_number'],
            $num
        ));
        return false;
    }

    /* 库存校验、规格校验等原有逻辑 */
    // ...
}

结算前最终校验

flow.php 的 done 步骤(提交订单)也要再校验一次,因为购物车里的数量可能在结算页被改:

// flow.php 的 done 分支
$cart_goods = $GLOBALS['db']->getAll(
    "SELECT c.goods_id, c.goods_number, g.min_number, g.goods_name
     FROM " . $GLOBALS['ecs']->table('cart') . " AS c
     LEFT JOIN " . $GLOBALS['ecs']->table('goods') . " AS g ON g.goods_id = c.goods_id
     WHERE c.session_id = '" . SESS_ID . "'
       AND c.parent_id = 0
       AND c.is_gift = 0"
);

foreach ($cart_goods as $cg) {
    if ($cg['min_number'] > 0 && $cg['goods_number'] < $cg['min_number']) {
        ecs_header("Location: flow.php?step=cart&err=min_number&gid={$cg['goods_id']}");
        exit;
    }
}

分阶梯起订量(按会员等级)

VIP 客户起订量更低、新客起订量更高的场景:

表结构

ALTER TABLE `ecs_goods`
ADD COLUMN `min_number_level` VARCHAR(255) DEFAULT NULL
AFTER `min_number`;

min_number_level 存 JSON 字符串,比如 {"1":50,"2":20,"3":10} 表示等级 1 起订 50、等级 2 起订 20、等级 3 起订 10。

取值逻辑

function get_user_min_number($goods, $user_rank) {
    if (empty($goods['min_number_level'])) {
        return $goods['min_number'];
    }
    $levels = json_decode($goods['min_number_level'], true);
    return isset($levels[$user_rank]) ? intval($levels[$user_rank]) : $goods['min_number'];
}

后台模板

给后台编辑页提供 JSON 编辑器或者一个简单的「等级 - 起订量」表格:

<tr>
    <td class="label">按等级起订量:</td>
    <td>
        <table>
            <tr><td>普通会员:</td><td><input name="ml[1]" value="{...}" /></td></tr>
            <tr><td>VIP1:</td><td><input name="ml[2]" value="{...}" /></td></tr>
            <tr><td>VIP2:</td><td><input name="ml[3]" value="{...}" /></td></tr>
        </table>
    </td>
</tr>

批量设置起订量

SKU 上千的店铺逐个改太慢。SQL 批量配置:

-- 整个分类(包括子分类)下的商品都设起订 10
UPDATE ecs_goods
SET min_number = 10
WHERE cat_id IN (
    SELECT cat_id FROM ecs_category
    WHERE parent_id = 5 OR cat_id = 5
);

-- 价格低于 10 元的商品起订 5 件(防包邮亏本)
UPDATE ecs_goods SET min_number = 5 WHERE shop_price < 10;

-- 重量小于 0.1kg 的轻物起订 10 件
UPDATE ecs_goods SET min_number = 10 WHERE goods_weight < 0.1;

与配送方式联动

不同配送方式起订量不同(国内快递起订 5 件,国际邮政起订 30 件)。需要给 ecs_shipping 表加字段:

ALTER TABLE ecs_shipping
ADD COLUMN min_qty INT DEFAULT 0,
ADD COLUMN max_qty INT DEFAULT 0;

结算页拿到购物车总数量后判断哪些配送方式可用:

$total_qty = array_sum(array_column($cart_goods, 'goods_number'));
$shippings = $GLOBALS['db']->getAll(
    "SELECT * FROM " . $GLOBALS['ecs']->table('shipping') . "
     WHERE enabled = 1
       AND (min_qty = 0 OR min_qty <= $total_qty)
       AND (max_qty = 0 OR max_qty >= $total_qty)"
);

促销活动与起订量冲突

限时秒杀「每人限购 3 件」与商品「起订 10 件」冲突时怎么处理?建议规则:

  • 如果是限购上限:起订量优先,超过限购的部分拒绝(限购更严格)。
  • 如果是「N 件折扣」促销:起订量基础上享受折扣(例:起订 10 件,买满 20 件 8 折)。
  • 如果是赠品促销:起订量满足后赠品照常发放。

代码里保留 min_number 校验在所有促销规则计算之前,强制最低门槛优先满足。

常见故障

故障 1:后台保存商品时报字段不存在

说明 ALTER TABLE 没成功执行,或者表前缀不对。SHOW COLUMNS FROM ecs_goods LIKE 'min_number'; 验证字段是否存在。

故障 2:前台默认数量没变

多数是模板缓存。后台「系统设置-清除缓存」+ 浏览器强刷。如果用了 nginx fastcgi_cache 也要清。

故障 3:用户改数量提交后丢失

原因:购物车更新时漏改 lib_order.php 的 update_cart 函数。需要在 update_cart 里加 min_number 校验,否则用户能在购物车页减到 1 件然后下单。

故障 4:JS 校验绕过后服务端没拦住

说明只做了前端 JS 校验,没改 lib_order.php。补全服务端校验。

故障 5:旧商品 min_number 字段是 NULL

ALTER TABLE 时设了 DEFAULT 0 但少数老商品仍是 NULL(升级路径里有过更改)。一次性修复:UPDATE ecs_goods SET min_number = 0 WHERE min_number IS NULL;

故障 6:批量价(按数量阶梯定价)与起订量冲突

ECSHOP 商品阶梯价存在 ecs_member_price 表,与 min_number 是独立机制。如果商品配置了阶梯价 1-9 件 100 元、10-99 件 90 元,但 min_number=10,前端默认数量 10、价格直接走 90 元档。两者互不干扰,需要的话可以去掉低于 min_number 档的阶梯价配置。

常见问题解答

min_number 设为 0 与 1 有区别吗?

有。min_number=0 表示「不限制」,前端默认显示 1,校验逻辑跳过。min_number=1 也表示「至少买 1 件」,效果与不限制相同但会触发校验逻辑(性能略微浪费)。建议默认设 0,需要起订量再设具体数字。

能否做最大购买数量限制?

能。同样手法加 max_number 字段。校验逻辑:if (qty > max_number && max_number > 0)。常用场景:限时秒杀每人限购 3 件。

起订量字段值很大(比如 50000)会有性能问题吗?

不会。SMALLINT 上限 65535,正常范围内不会触发性能问题。如果你的批发场景需要起订几十万,改用 INT 类型。

起订量与库存数量哪个优先?

库存优先。如果商品库存只有 5 件但起订量 10,应当显示「库存不足」而不是「起订 10 件」。校验顺序:先库存判断(有货才允许下单),再起订量判断。

能否给特定会员组关闭起订量?

能。在 lib_order.php 的校验里加判断:if (in_array($_SESSION['user_rank'], [3, 4])) { /* VIP3 与 VIP4 跳过起订量校验 */ }。或者用前面讲的「分阶梯起订量」方案。

修改后能否一键回滚?

能。代码改动 git 管理,数据库 ALTER 用 mysqldump 备份。回滚 SQL:ALTER TABLE ecs_goods DROP COLUMN min_number; 删字段;代码恢复 git revert。事先做好备份是关键。

能否给配送方式按重量起订?

类似配送方式起订数量,把 min_qty 改成 min_weight,结算时算 sum(goods_weight * goods_number) 与 min_weight 比较。逻辑相同。

ECSHOP 4.x 怎么实现起订量?

ECSHOP 4.x 重写了商品模型,min_number 字段位置改到 goods_v4 表。后台编辑模板路径不同,但思路一致:加字段 - 改表单 - 改前后端校验。

能否在商品列表页就显示起订量提示?

能。category.dwt 的商品卡片渲染里加:{if $goods.min_number > 0}<span class="min-qty">{$goods.min_number}件起订</span>{/if}。注意 PLP 模板的 controller(category.php)要在 SQL 查询时把 min_number 字段也取出来。

分销商系统怎么处理起订量?

分销商场景多见于 B2B2C 模式。ECSHOP 自带分销不强,建议直接用「按等级起订量」方案,给分销商账号分配 VIP3/VIP4 等级享受最低起订量。

分享到
标签
版权声明

本文标题:《ECSHOP 商品最小起订量完整二开方案:min_number 字段、分阶梯按等级、配送方式联动与购物车二次校验》

本文链接:https://zhangwenbao.com/ecshop-sets-the-minimum-number-of-purchases-for-goods.html

版权声明:本文原创,转载请注明出处和链接。许可协议: CC BY-NC-SA 4.0

继续阅读
发表评论
分享到微信 或在下方手动填写
支持 Ctrl + Enter 提交