This commit is contained in:
yumoqing 2026-05-11 11:30:57 +08:00
parent a25c9e56dc
commit b41c89e9dd
11 changed files with 543 additions and 186 deletions

View File

@ -1,7 +1,61 @@
# discount # discount
## 功能 ## 功能
支持商户为客户设置折扣,一旦客户设置折扣,商户的所有商品此客户均享受商户为其设置的折扣 支持商户为客户设置折扣,可以为不同产品设置不同的折扣值。
## ## 数据模型
### discount (折扣表)
主折扣记录表,定义商户-客户关系的折扣。
- id: 折扣id
- name: 折扣名称
- resellerid: 商户id
- customerid: 客户id (可为NULL表示商户级默认折扣)
- enabled_date: 启用日期
- expired_date: 失效日期
### discount_detail (折扣产品明细表)
存储每个产品的具体折扣值。
- id: 明细id
- discountid: 关联折扣id
- resellerid: 商户id
- prodtypeid: 产品类型id (可为NULL表示所有类型)
- productid: 产品id (可为NULL表示该类型下所有产品)
- discount: 折扣值 (0 < discount < 1)
### discount_qr (折扣二维码表)
促销二维码记录。
- id: 二维码id
- resellerid: 商户id
- discount: 折扣值
- valid_term: 有效期 (如 30D, 3M, 1Y)
- expired_date: 促销失效日期
- qr_webpath: 二维码路径
## 折扣查询
### 获取产品折扣
```python
from discount.init import get_product_discount
discount = await get_product_discount(resellerid, customerid, prodtypeid, productid)
# 返回折扣值 (float)1.0表示无折扣
```
查询优先级:
1. 精确匹配:产品类型 + 产品id
2. 类型级别匹配:产品类型 (productid为NULL)
3. 默认匹配:所有产品 (prodtypeid和productid均为NULL)
### 获取客户默认折扣 (兼容旧接口)
```python
from discount.init import get_customer_discount
discount = await get_customer_discount(resellerid, customerid)
# 返回客户默认折扣值
```
## 维护界面
- 折扣管理: discount_list.ui - 创建/编辑/删除折扣记录
- 折扣产品明细: discount_detail_list.ui?discountid=xxx - 为指定折扣添加产品明细,设置各产品折扣值
- 生成促销码: promote.ui - 生成促销二维码

1
discount/__init__.py Normal file
View File

@ -0,0 +1 @@

View File

@ -1,144 +1,340 @@
from datetime import datetime, timedelta
from appPublic.qr import gen_qr_withlogo from appPublic.qr import gen_qr_withlogo
from appPublic.uniqueID import getID from appPublic.uniqueID import getID
from appPublic.jsonConfig import getConfig from appPublic.jsonConfig import getConfig
from appPublic.dictObject import DictObject from appPublic.dictObject import DictObject
from sqlor.dbpools import get_sor_context from sqlor.dbpools import DBPools
from ahserver.serverenv import ServerEnv from ahserver.serverenv import ServerEnv
from ahserver.filestorage import FileStorage from ahserver.filestorage import FileStorage
async def discount_qrcode(request, params_kw):
"""
discount less then 1 and greate then 0
valid_term is digit + one of ["D", "M", "Y"]
expired_date is a date after it the promote qrcode invalidable
"""
discount = params_kw.discount
valid_term = params_kw.valid_term
expired_date = params_kw.expired_date
env = request._run_ns
resellerid = await env.get_userorgid()
id = getID()
url = env.entire_url('./promote') + f'?id={id}'
fs = FileStorage()
p = fs._name2path(f'{getID()}.png')
config = getConfig()
gen_qr_withlogo(url, p, logopath=config.logopath, logoloc='cc')
webp = fs.webpath(p)
async with get_sor_context(env, 'discount') as sor:
biz_date = await env.get_business_date(sor)
if biz_date >= expired_date:
raise Exception('Promote QRCODE is out of time')
ret = { async def discount_qrcode(request, params_kw):
'id': id, """
'resellerid': resellerid, Generate a promotional QR code for discount
'discount': discount, discount less then 1 and greate then 0
'valid_term': valid_term, valid_term is digit + one of ["D", "M", "Y"]
'expired_date': expired_date, expired_date is a date after it the promote qrcode invalidable
'qr_webpath': webp """
} discount = params_kw.discount
await sor.C('discount_qr', ret.copy()) valid_term = params_kw.valid_term
return DictObject(**ret) expired_date = params_kw.expired_date
return None env = ServerEnv()
dbname = env.get_module_dbname('discount')
config = getConfig()
db = DBPools()
db.databases = config.databases
resellerid = await env.get_userorgid()
qr_id = getID()
url = env.entire_url('./promote') + f'?id={qr_id}'
fs = FileStorage()
p = fs._name2path(f'{getID()}.png')
gen_qr_withlogo(url, p, logopath=config.logopath, logoloc='cc')
webp = fs.webpath(p)
async with db.sqlorContext(dbname) as sor:
biz_date = await env.get_business_date(sor)
if biz_date >= expired_date:
raise Exception('Promote QRCODE is out of time')
ret = {
'id': qr_id,
'resellerid': resellerid,
'discount': discount,
'valid_term': valid_term,
'expired_date': expired_date,
'qr_webpath': webp
}
await sor.C('discount_qr', ret.copy())
return DictObject(**ret)
return None
async def set_promote_discount(request, params_kw): async def set_promote_discount(request, params_kw):
env = request._run_ns env = ServerEnv()
id = params_kw.id dbname = env.get_module_dbname('discount')
customerid = await env.get_userorgid() config = getConfig()
async with get_sor_context(env, 'discount') as sor: db = DBPools()
recs = await sor.R('discount_qr', {'id': id}) db.databases = config.databases
if not recs: id = params_kw.id
raise Exception(f'promote id({id}) not exists') customerid = await env.get_userorgid()
biz_date = await env.get_business_date(sor) async with db.sqlorContext(dbname) as sor:
if recs[0].expired_date <= biz_date: recs = await sor.R('discount_qr', {'id': id})
raise Exception('Promote QRCODE is out of time') if not recs:
cnt = int(recs[0].valid_term[:-1]) raise Exception(f'promote id({id}) not exists')
unit = recs[0].valid_term[-1] biz_date = await env.get_business_date(sor)
enabled_date = biz_date if recs[0].expired_date <= biz_date:
expired_date = '' raise Exception('Promote QRCODE is out of time')
if unit == 'D': cnt = int(recs[0].valid_term[:-1])
expired_date = env.strdate_add(enabled_date, days=cnt) unit = recs[0].valid_term[-1]
elif unit == 'M': enabled_date = biz_date
expired_date = env.strdate_add(enabled_date, months=cnt) expired_date = ''
elif unit == 'Y': if unit == 'D':
expired_date = env.strdate_add(enabled_date, years=cnt) expired_date = env.strdate_add(enabled_date, days=cnt)
else: elif unit == 'M':
raise Exception(f'Invalid valid_term({recs[0].valid_term})') expired_date = env.strdate_add(enabled_date, months=cnt)
need_new_discount = await disable_old_discount(sor, recs[0].resellerid, elif unit == 'Y':
customerid, biz_date, recs[0].discount) expired_date = env.strdate_add(enabled_date, years=cnt)
if not need_new_discount: else:
return await sor_get_customer_discount(sor, recs[0].resellerid, customerid) raise Exception(f'Invalid valid_term({recs[0].valid_term})')
ret = {
'id': getID(), # Disable old active discount for this customer
await disable_old_discount(sor, recs[0].resellerid, customerid, biz_date)
# Create new discount record (no longer stores discount value directly)
discountid = getID()
ret = {
'id': discountid,
'name': f'促销折扣-{biz_date}',
'resellerid': recs[0].resellerid, 'resellerid': recs[0].resellerid,
'customerid': customerid, 'customerid': customerid,
'discount': recs[0].discount,
'enabled_date': enabled_date, 'enabled_date': enabled_date,
'expired_date': expired_date 'expired_date': expired_date
} }
await sor.C('discount', ret.copy()) await sor.C('discount', ret.copy())
return recs[0].discount
return None # Create discount_detail record with the discount value from QR code
# prodtypeid=None, productid=None means applies to all products
async def disable_old_discount(sor, resellerid, customerid, biz_date, new_discount): detail_ret = {
sql = """select * from discount 'id': getID(),
where resellerid = ${resellerid}$ 'discountid': discountid,
and customerid=${customerid}$ 'resellerid': recs[0].resellerid,
and enabled_date <= ${biz_date}$ 'prodtypeid': None,
and expired_date > ${biz_date}$ for update""" 'productid': None,
recs = await sor.sqlExe(sql, {'resellerid': resellerid, 'customerid': customerid, 'biz_date': biz_date}) 'discount': recs[0].discount,
if not recs: }
return True await sor.C('discount_detail', detail_ret.copy())
if new_discount > recs[0].discount:
return False return recs[0].discount
await sor.U('discount', {'id': recs[0].id, 'expired_date': biz_date}) return None
return True
async def disable_old_discount(sor, resellerid, customerid, biz_date):
"""Disable any active discount record for the given reseller+customer pair."""
# Use sor.R with sort instead of raw SQL + FOR UPDATE (DB-agnostic)
recs = await sor.R('discount', {
'resellerid': resellerid,
'customerid': customerid,
'sort': 'enabled_date desc'
})
if not recs:
return
# Find the active one in Python (DB-agnostic date comparison)
for rec in recs:
if rec.get('enabled_date', '') <= biz_date and rec.get('expired_date', '') > biz_date:
await sor.U('discount', {'id': rec['id'], 'expired_date': biz_date})
return
async def sor_get_star_discount(sor, resellerid, biz_date): async def sor_get_star_discount(sor, resellerid, biz_date):
env = ServerEnv() """Get default discount for a reseller (no specific customer)."""
sql = """select * from discount sql = """select d.id from discount d
where resellerid = ${resellerid}$ where d.resellerid = ${resellerid}$
and enabled_date <= ${biz_date}$ and d.customerid is NULL
and expired_date > ${biz_date}$""" and d.enabled_date <= ${biz_date}$
ns = { and d.expired_date > ${biz_date}$"""
"resellerid": resellerid, ns = {
"biz_date": biz_date "resellerid": resellerid,
} "biz_date": biz_date
recs = await sor.sqlExe(sql, ns) }
if not recs: recs = await sor.sqlExe(sql, ns)
return 1 if not recs:
return 1
return recs[0].discount
# Look up default discount detail (prodtypeid=None, productid=None)
sql2 = """select discount from discount_detail
where discountid = ${discountid}$
and prodtypeid is NULL
and productid is NULL"""
recs2 = await sor.sqlExe(sql2, {'discountid': recs[0].id})
if not recs2:
return 1
return recs2[0].discount
async def sor_get_customer_discount(sor, resellerid, customerid): async def sor_get_customer_discount(sor, resellerid, customerid):
env = ServerEnv() """Get discount record for a customer (legacy, returns record not value).
biz_date = await env.get_business_date(sor) Use sor_get_product_discount for product-specific discount."""
sql = """select * from discount env = ServerEnv()
biz_date = await env.get_business_date(sor)
sql = """select * from discount
where resellerid = ${resellerid}$ where resellerid = ${resellerid}$
and customerid = ${customerid}$ and customerid = ${customerid}$
and enabled_date <= ${biz_date}$ and enabled_date <= ${biz_date}$
and expired_date > ${biz_date}$""" and expired_date > ${biz_date}$"""
ns = { ns = {
"resellerid": resellerid, "resellerid": resellerid,
"customerid": customerid, "customerid": customerid,
"biz_date": biz_date "biz_date": biz_date
} }
recs = await sor.sqlExe(sql, ns) recs = await sor.sqlExe(sql, ns)
if not recs: if not recs:
return await sor_get_star_discount(sor, resellerid, biz_date) return await sor_get_star_discount(sor, resellerid, biz_date)
return recs[0].discount # Return default discount for this customer (prodtypeid=None, productid=None)
sql2 = """select discount from discount_detail
where discountid = ${discountid}$
and prodtypeid is NULL
and productid is NULL"""
recs2 = await sor.sqlExe(sql2, {'discountid': recs[0].id})
if not recs2:
return 1
return recs2[0].discount
async def sor_get_product_discount(sor, resellerid, customerid, prodtypeid, productid):
"""
Get product-specific discount.
Lookup priority:
1. Exact match: discountid -> (prodtypeid, productid)
2. Type-level match: discountid -> (prodtypeid, NULL)
3. Default match: discountid -> (NULL, NULL)
Returns discount value (float), default 1.0 (no discount).
"""
env = ServerEnv()
biz_date = await env.get_business_date(sor)
# Step 1: Find active discount record for reseller+customer
sql = """select id from discount
where resellerid = ${resellerid}$
and customerid = ${customerid}$
and enabled_date <= ${biz_date}$
and expired_date > ${biz_date}$"""
ns = {
"resellerid": resellerid,
"customerid": customerid,
"biz_date": biz_date
}
recs = await sor.sqlExe(sql, ns)
if not recs:
# Try reseller-level default (customerid is NULL)
sql = """select id from discount
where resellerid = ${resellerid}$
and customerid is NULL
and enabled_date <= ${biz_date}$
and expired_date > ${biz_date}$"""
recs = await sor.sqlExe(sql, ns)
if not recs:
return 1.0
discountid = recs[0].id
# Step 2: Try exact product match
sql2 = """select discount from discount_detail
where discountid = ${discountid}$
and prodtypeid = ${prodtypeid}$
and productid = ${productid}$"""
recs2 = await sor.sqlExe(sql2, {
'discountid': discountid,
'prodtypeid': prodtypeid,
'productid': productid
})
if recs2:
return recs2[0].discount
# Step 3: Try product type level match (productid is NULL)
sql3 = """select discount from discount_detail
where discountid = ${discountid}$
and prodtypeid = ${prodtypeid}$
and productid is NULL"""
recs3 = await sor.sqlExe(sql3, {
'discountid': discountid,
'prodtypeid': prodtypeid,
})
if recs3:
return recs3[0].discount
# Step 4: Try default match (both NULL)
sql4 = """select discount from discount_detail
where discountid = ${discountid}$
and prodtypeid is NULL
and productid is NULL"""
recs4 = await sor.sqlExe(sql4, {'discountid': discountid})
if recs4:
return recs4[0].discount
return 1.0
async def get_customer_discount(resellerid, customerid): async def get_customer_discount(resellerid, customerid):
env = ServerEnv() """Legacy: get default discount for a customer (all products)."""
async with get_sor_context(env, 'discount') as sor: env = ServerEnv()
return await sor_get_customer_discount(sor, resellerid, customerid) dbname = env.get_module_dbname('discount')
return 1 config = getConfig()
db = DBPools()
db.databases = config.databases
async with db.sqlorContext(dbname) as sor:
return await sor_get_customer_discount(sor, resellerid, customerid)
return 1
async def get_product_discount(resellerid, customerid, prodtypeid, productid):
"""
Get product-specific discount.
Parameters:
resellerid: merchant ID
customerid: customer ID
prodtypeid: product type ID
productid: product ID
Returns: discount value (float), 1.0 means no discount.
"""
env = ServerEnv()
dbname = env.get_module_dbname('discount')
config = getConfig()
db = DBPools()
db.databases = config.databases
async with db.sqlorContext(dbname) as sor:
return await sor_get_product_discount(sor, resellerid, customerid, prodtypeid, productid)
return 1.0
async def get_discount_details(sor, discountid):
"""Get all product detail records for a given discount."""
sql = """select * from discount_detail
where discountid = ${discountid}$
order by prodtypeid, productid"""
recs = await sor.sqlExe(sql, {'discountid': discountid})
return recs
async def add_discount_detail(sor, discountid, resellerid, prodtypeid, productid, discount):
"""Add a product-specific discount detail."""
if discount <= 0 or discount >= 1:
raise Exception(f'discount({discount}) invalid, must be between 0 and 1')
ret = {
'id': getID(),
'discountid': discountid,
'resellerid': resellerid,
'prodtypeid': prodtypeid if prodtypeid else None,
'productid': productid if productid else None,
'discount': discount,
}
await sor.C('discount_detail', ret.copy())
return ret
async def update_discount_detail(sor, detail_id, discount):
"""Update a discount detail record."""
if discount <= 0 or discount >= 1:
raise Exception(f'discount({discount}) invalid, must be between 0 and 1')
await sor.U('discount_detail', {'id': detail_id, 'discount': discount})
async def delete_discount_detail(sor, detail_id):
"""Delete a discount detail record."""
await sor.D('discount_detail', {'id': detail_id})
def load_discount(): def load_discount():
env = ServerEnv() env = ServerEnv()
env.get_customer_discount = get_customer_discount env.get_customer_discount = get_customer_discount
env.sor_get_customer_discount = sor_get_customer_discount env.sor_get_customer_discount = sor_get_customer_discount
env.discount_qrcode = discount_qrcode env.get_product_discount = get_product_discount
env.set_promote_discount = set_promote_discount env.sor_get_product_discount = sor_get_product_discount
env.discount_qrcode = discount_qrcode
env.set_promote_discount = set_promote_discount
env.get_discount_details = get_discount_details
env.add_discount_detail = add_discount_detail
env.update_discount_detail = update_discount_detail
env.delete_discount_detail = delete_discount_detail

View File

@ -0,0 +1,52 @@
{
"tblname": "discount_detail",
"alias": "discount_detail_list",
"title": "折扣产品明细",
"params": {
"sortby": ["prodtypeid", "productid"],
"browserfields": {
"exclouded": ["id", "resellerid"],
"alters": {
"discountid": {
"uitype": "code",
"dataurl": "/get_code.dspy",
"datamethod": "GET",
"dataparams": {
"dbname": "sage",
"table": "discount",
"tblvalue": "id",
"tbltext": "name"
}
},
"prodtypeid": {
"uitype": "code",
"dataurl": "/get_code.dspy",
"datamethod": "GET",
"dataparams": {
"dbname": "sage",
"table": "prodtype",
"tblvalue": "id",
"tbltext": "name"
}
},
"productid": {
"uitype": "code",
"dataurl": "/get_code.dspy",
"datamethod": "GET",
"dataparams": {
"dbname": "sage",
"table": "product",
"tblvalue": "id",
"tbltext": "name"
}
}
}
},
"editexclouded": ["id", "discountid", "resellerid"],
"editable": {
"new_data_url": "{{entire_url('../api/discount_detail_create.dspy')}}",
"update_data_url": "{{entire_url('../api/discount_detail_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/discount_detail_delete.dspy')}}"
}
}
}

41
json/discount_list.json Normal file
View File

@ -0,0 +1,41 @@
{
"tblname": "discount",
"alias": "discount_list",
"title": "折扣管理",
"params": {
"sortby": ["enabled_date desc"],
"browserfields": {
"exclouded": ["id"],
"alters": {
"resellerid": {
"uitype": "code",
"dataurl": "/get_code.dspy",
"datamethod": "GET",
"dataparams": {
"dbname": "sage",
"table": "organization",
"tblvalue": "id",
"tbltext": "orgname"
}
},
"customerid": {
"uitype": "code",
"dataurl": "/get_code.dspy",
"datamethod": "GET",
"dataparams": {
"dbname": "sage",
"table": "organization",
"tblvalue": "id",
"tbltext": "orgname"
}
}
}
},
"editexclouded": ["id"],
"editable": {
"new_data_url": "{{entire_url('../api/discount_create.dspy')}}",
"update_data_url": "{{entire_url('../api/discount_update.dspy')}}",
"delete_data_url": "{{entire_url('../api/discount_delete.dspy')}}"
}
}
}

Binary file not shown.

BIN
models/discount_detail.xlsx Normal file

Binary file not shown.

View File

@ -12,9 +12,8 @@ authors = [
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"apppublic", "sqlor",
"sqlor", "bricks_for_python"
"ahserver"
] ]
[tool.setuptools] [tool.setuptools]

View File

@ -1,58 +1,63 @@
from ahserver.serverenv import ServerEnv
params_kw.discount = float(params_kw.discount) params_kw.discount = float(params_kw.discount)
if params_kw.discount <= 0 or params_kw.discount >= 1: if params_kw.discount <= 0 or params_kw.discount >= 1:
e = Exception(f'discount({params_kw.discount}) invalid') e = Exception(f'discount({params_kw.discount}) invalid')
exception(f'{e}') exception(f'{e}')
raise e raise e
if params_kw.valid_term[-1] not in ['D', 'M', 'Y']: if params_kw.valid_term[-1] not in ['D', 'M', 'Y']:
e = Exception(f'valid_term must ends with "D", "M" or "Y"') e = Exception(f'valid_term must ends with "D", "M" or "Y"')
exception(f'{e}') exception(f'{e}')
raise e raise e
cnt = int(params_kw.valid_term[:-1]) cnt = int(params_kw.valid_term[:-1])
if cnt < 0: if cnt < 0:
e = Exception(f'valid_term({params_kw.valid_term}) invalid') e = Exception(f'valid_term({params_kw.valid_term}) invalid')
exception(f'{e}') exception(f'{e}')
raise e raise e
if not params_kw.expired_date: if not params_kw.expired_date:
params_kw.expired_date = '9999-12-31' params_kw.expired_date = '9999-12-31'
x = await discount_qrcode(request, params_kw) x = await discount_qrcode(request, params_kw)
return {
"widgettype": "VBox",
"options": {
"width": "100%",
"height": "100%"
},
"subwidgets": [
{
"widgettype": "Image",
"options": {
"width": "340px",
"height": "340px",
"url": entire_url('/idfile') + f'?path={x.qr_webpath}'
}
}, {
"widgettype": "HBox",
"options": {
"width": "100%",
"cheight": 2
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"otext": "折扣:",
"i18n": True,
"cwidth": 3
}
},{
"widgettype": "Text",
"options":{
"text": x.discount
}
}
]
}
]
}
env = ServerEnv()
qr_url = env.entire_url('/idfile') + f'?path={x.qr_webpath}'
return {
"widgettype": "VBox",
"options": {
"width": "100%",
"height": "100%"
},
"subwidgets": [
{
"widgettype": "Image",
"options": {
"width": "340px",
"height": "340px",
"url": qr_url
}
}, {
"widgettype": "HBox",
"options": {
"width": "100%",
"cheight": 2
},
"subwidgets": [
{
"widgettype": "Text",
"options": {
"otext": "折扣:",
"i18n": True,
"cwidth": 3
}
},{
"widgettype": "Text",
"options":{
"text": x.discount
}
}
]
}
]
}

View File

@ -5,11 +5,21 @@
"target":"root.page_center", "target":"root.page_center",
"cwidth":10, "cwidth":10,
"items":[ "items":[
{ {
"name":"promotecode", "name":"discount_list",
"label": "生成促销码", "label": "折扣管理",
"url": "{{entire_url('promote.ui')}}", "url": "{{entire_url('discount_list.ui')}}"
} },
] {
} "name":"discount_detail_list",
"label": "折扣产品明细",
"url": "{{entire_url('discount_detail_list.ui')}}"
},
{
"name":"promotecode",
"label": "生成促销码",
"url": "{{entire_url('promote.ui')}}"
}
]
}
} }

View File

@ -3,6 +3,8 @@
"options": { "options": {
"width": "100%", "width": "100%",
"height": "100%", "height": "100%",
"url": "{{entire_url('generate_qr.dspy')}}",
"method": "POST",
"fields": [ "fields": [
{ {
"name": "valid_term", "name": "valid_term",
@ -16,7 +18,7 @@
"tip": "促销二维码需在此日期前使用" "tip": "促销二维码需在此日期前使用"
},{ },{
"name": "discount", "name": "discount",
"lable": "折扣", "label": "折扣",
"uitype": "float", "uitype": "float",
"tip": "折扣>0, < 1, 最终价格=产品价格*折扣, 折扣越小,折扣力度越大" "tip": "折扣>0, < 1, 最终价格=产品价格*折扣, 折扣越小,折扣力度越大"
} }
@ -25,13 +27,10 @@
"binds":[ "binds":[
{ {
"wid": "self", "wid": "self",
"event": "submit", "event": "submited",
"actiontype": "urlwidget", "actiontype": "script",
"target": "self", "target": "self",
"options": { "script": "await bricks.show_resp_message_or_error(event.params)"
"url": "{{entire_url('generate_qr.dspy')}}",
"params": {}
}
} }
] ]
} }