ECSHOP 默认没有「商品最小起订量」功能。运营做包邮活动时常遇到这种困境:定个 50 元包邮,结果用户买一支 5 元的笔订单也包邮,倒贴运费 12 元。或者批发型店铺希望小客户买 10 件起订才划算,单卖一件不接。本文给出从数据库表结构、后台编辑界面、前台 JS 校验、加车流程拦截、与促销活动联动、配送规则配合这一整套「最小起订量」的二次开发方案,覆盖 ECSHOP 2.7.x 的完整改造,并扩展到分阶梯起订(不同会员等级不同最小数量)、配送方式起订(国际订单起订量更高)、按商品分类批量设置起订量等运营常见需求。
需求场景分析
三种典型起订量场景
- 包邮门槛防漏:单件价格低于运费的商品,强制起订量让总价超过包邮成本。例如笔类单价 5 元,运费 12 元,设起订量 5 件。
- 批发型店铺:散户用零售平台,B2B 客户走最小起订量。整箱、整托盘起订。
- 定制商品:印刷、刺绣、3D 打印等需要开模或备料的商品,少于 N 件不接单(材料成本与利润摊不开)。
方案选型
有三种实现路径:
- 纯前端 JS 限制:改 goods.dwt 模板里的 input,加 onkeyup 校验。优点是改动小,缺点是用户禁用 JS 或直接构造 POST 请求绕过。
- 商品级别字段 min_number:给 ecs_goods 表加字段,每个商品独立设最小起订量,前后端联动。本文主推方案。
- 分类级别配置:给 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 等级享受最低起订量。