feat: 修复 H5 底部导航覆盖 + 更新项目进度文档
## H5 底部导航修复 (Bug #10) - 精简 App.vue,移除重复 tabbar,仅保留全局样式 - uni-page 设置 height: calc(100% - 50px) + overflow-y: auto - 内容区域精确停在底部导航上方,独立滚动不再叠加 - 恢复 custom-tab-bar 组件 ## 项目进度文档 - PROGRESS.md 更新至 10 个 Bug 修复 - 新增 H5 底部导航修复记录 - 新增历史变更条目
This commit is contained in:
+155
-57
@@ -10,6 +10,9 @@ logger = logging.getLogger(__name__)
|
||||
def check_silent_customers():
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.customer import Customer
|
||||
from app.models.user import User
|
||||
from app.services.push import PushService
|
||||
from app.services.notification import NotificationService
|
||||
|
||||
async def _check():
|
||||
async with AsyncSessionLocal() as db:
|
||||
@@ -27,12 +30,26 @@ def check_silent_customers():
|
||||
)
|
||||
customers = result.scalars().all()
|
||||
for c in customers:
|
||||
if days == 3:
|
||||
logger.info(f"Customer {c.name} silent for 3 days")
|
||||
elif days == 7:
|
||||
logger.info(f"Customer {c.name} silent for 7 days - upgrade")
|
||||
else:
|
||||
logger.info(f"Customer {c.name} silent for 14 days - recommend new approach")
|
||||
messages = {
|
||||
3: ("跟进提醒", f"客户 {c.name} 已沉默3天,建议发送跟进消息"),
|
||||
7: ("跟进升级", f"客户 {c.name} 已沉默1周,建议发送优惠或新产品信息"),
|
||||
14: ("跟进提示", f"客户 {c.name} 已沉默14天,建议换话题重新接触"),
|
||||
}
|
||||
title, content = messages.get(days, ("跟进提醒", f"客户 {c.name} 已沉默{days}天"))
|
||||
logger.info(f"Customer {c.name} silent for {days} days")
|
||||
|
||||
user_result = await db.execute(
|
||||
select(User).where(User.id == c.user_id)
|
||||
)
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user:
|
||||
PushService.send_notification(c.user_id, title, content)
|
||||
await NotificationService.create_notification(
|
||||
db, c.user_id, title, content,
|
||||
notification_type="customer_silent",
|
||||
reference_type="customer",
|
||||
reference_id=str(c.id),
|
||||
)
|
||||
|
||||
import asyncio
|
||||
asyncio.run(_check())
|
||||
@@ -59,6 +76,8 @@ def batch_translate_texts(texts: list, target_lang: str, user_id: str):
|
||||
def generate_quotation_pdf(quotation_id: str):
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.quotation import Quotation, QuotationItem
|
||||
from app.models.customer import Customer
|
||||
from app.services.pdf_generator import pdf_generator
|
||||
|
||||
async def _generate():
|
||||
async with AsyncSessionLocal() as db:
|
||||
@@ -74,62 +93,60 @@ def generate_quotation_pdf(quotation_id: str):
|
||||
)
|
||||
items = items_result.scalars().all()
|
||||
|
||||
pdf_content = generate_pdf_text(q, items)
|
||||
customer = None
|
||||
if q.customer_id:
|
||||
cust_result = await db.execute(
|
||||
select(Customer).where(Customer.id == q.customer_id)
|
||||
)
|
||||
customer = cust_result.scalar_one_or_none()
|
||||
|
||||
return {"pdf_content": pdf_content, "quotation_id": str(q.id)}
|
||||
data = {
|
||||
"quotation_number": f"{str(q.id)[:8].upper()}",
|
||||
"customer_name": customer.name if customer else "",
|
||||
"customer_company": customer.company if customer else "",
|
||||
"customer_country": customer.country if customer else "",
|
||||
"date": q.created_at.strftime("%Y-%m-%d") if q.created_at else "",
|
||||
"valid_until": q.valid_until or "",
|
||||
"currency": q.currency or "USD",
|
||||
"items": [
|
||||
{
|
||||
"product_name": i.product_name,
|
||||
"description": i.description,
|
||||
"quantity": i.quantity,
|
||||
"unit_price": i.unit_price,
|
||||
"total_price": i.total_price,
|
||||
"unit": i.unit or "pcs",
|
||||
}
|
||||
for i in items
|
||||
],
|
||||
"subtotal": q.subtotal or 0,
|
||||
"discount": q.discount or 0,
|
||||
"shipping": q.shipping or 0,
|
||||
"total": q.total or q.subtotal or 0,
|
||||
"payment_terms": q.payment_terms or "",
|
||||
"delivery_terms": q.delivery_terms or "",
|
||||
"lead_time": q.lead_time or "",
|
||||
"notes": q.notes or "",
|
||||
}
|
||||
|
||||
pdf_bytes = pdf_generator.generate_quotation(data)
|
||||
|
||||
if pdf_bytes:
|
||||
upload_dir = settings.UPLOAD_DIR
|
||||
pdf_path = os.path.join(upload_dir, f"quotation_{quotation_id}.pdf")
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
with open(pdf_path, "wb") as f:
|
||||
f.write(pdf_bytes)
|
||||
q.pdf_url = pdf_path
|
||||
await db.flush()
|
||||
return {"success": True, "pdf_path": pdf_path, "quotation_id": str(q.id)}
|
||||
else:
|
||||
return {"error": "PDF generation failed (weasyprint not available)", "quotation_id": str(q.id)}
|
||||
|
||||
import asyncio
|
||||
return asyncio.run(_generate())
|
||||
|
||||
|
||||
def generate_pdf_text(quotation, items):
|
||||
from datetime import datetime
|
||||
|
||||
lines = [
|
||||
"=" * 60,
|
||||
f"QUOTATION",
|
||||
f"#{str(quotation.id)[:8].upper()}",
|
||||
"=" * 60,
|
||||
f"Date: {datetime.utcnow().strftime('%Y-%m-%d')}",
|
||||
]
|
||||
|
||||
if quotation.valid_until:
|
||||
lines.append(f"Valid Until: {quotation.valid_until}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(f"{'Item':<30} {'Qty':<8} {'Unit Price':<12} {'Total':<12}")
|
||||
lines.append("-" * 62)
|
||||
|
||||
for item in items:
|
||||
lines.append(
|
||||
f"{item.product_name:<30} {item.quantity:<8} ${item.unit_price:<10.2f} ${item.total_price:<10.2f}"
|
||||
)
|
||||
|
||||
lines.append("-" * 62)
|
||||
if quotation.subtotal:
|
||||
lines.append(f"{'Subtotal':>48} ${quotation.subtotal:<10.2f}")
|
||||
if quotation.discount:
|
||||
lines.append(f"{'Discount':>48} -${quotation.discount:<10.2f}")
|
||||
if quotation.shipping:
|
||||
lines.append(f"{'Shipping':>48} ${quotation.shipping:<10.2f}")
|
||||
lines.append(f"{'TOTAL':>48} ${quotation.total or quotation.subtotal or 0:<10.2f}")
|
||||
|
||||
lines.append("")
|
||||
if quotation.payment_terms:
|
||||
lines.append(f"Payment Terms: {quotation.payment_terms}")
|
||||
if quotation.delivery_terms:
|
||||
lines.append(f"Delivery Terms: {quotation.delivery_terms}")
|
||||
if quotation.lead_time:
|
||||
lines.append(f"Lead Time: {quotation.lead_time}")
|
||||
if quotation.notes:
|
||||
lines.append(f"Notes: {quotation.notes}")
|
||||
|
||||
lines.append("=" * 60)
|
||||
lines.append("Generated by TradeMate")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@shared_task
|
||||
def process_corpus_quality():
|
||||
from app.database import AsyncSessionLocal
|
||||
@@ -155,6 +172,71 @@ def process_corpus_quality():
|
||||
return asyncio.run(_process())
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3, default_retry_delay=60)
|
||||
def process_customer_import(self, user_id: str, records: list):
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.services.customer import CustomerService
|
||||
|
||||
async def _import():
|
||||
async with AsyncSessionLocal() as db:
|
||||
svc = CustomerService(db)
|
||||
imported = 0
|
||||
errors = []
|
||||
for i, record in enumerate(records):
|
||||
try:
|
||||
await svc.create_customer(user_id, record)
|
||||
imported += 1
|
||||
except Exception as e:
|
||||
errors.append(f"Row {i+2}: {str(e)}")
|
||||
return {"imported": imported, "total": len(records), "errors": errors}
|
||||
|
||||
import asyncio
|
||||
return asyncio.run(_import())
|
||||
|
||||
|
||||
@shared_task
|
||||
def run_daily_corpus_training():
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.services.corpus_trainer import CorpusTrainer
|
||||
|
||||
async def _train():
|
||||
async with AsyncSessionLocal() as db:
|
||||
trainer = CorpusTrainer(db)
|
||||
result = await trainer.run_pipeline()
|
||||
logger.info(f"Daily corpus training complete: {result}")
|
||||
return result
|
||||
|
||||
import asyncio
|
||||
return asyncio.run(_train())
|
||||
|
||||
|
||||
@shared_task
|
||||
def update_customer_health_cache():
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.services.customer_health import CustomerHealthService
|
||||
from app.models.user import User
|
||||
from app.config import settings
|
||||
|
||||
async def _update():
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(select(User.id))
|
||||
user_ids = result.scalars().all()
|
||||
|
||||
svc = CustomerHealthService(db)
|
||||
|
||||
for uid in user_ids:
|
||||
try:
|
||||
overview = await svc.get_health_overview(uid)
|
||||
scores = await svc.get_all_health_scores(uid)
|
||||
except Exception as e:
|
||||
logger.error(f"Health cache failed for user {uid}: {e}")
|
||||
|
||||
return f"Updated health cache for {len(user_ids)} users"
|
||||
|
||||
import asyncio
|
||||
return asyncio.run(_update())
|
||||
|
||||
|
||||
@shared_task
|
||||
def cleanup_old_sessions():
|
||||
import redis.asyncio as aioredis
|
||||
@@ -190,4 +272,20 @@ def send_followup_reminder(customer_id: str, user_id: str):
|
||||
return {"error": "Customer not found"}
|
||||
|
||||
import asyncio
|
||||
return asyncio.run(_send())
|
||||
return asyncio.run(_send())
|
||||
|
||||
|
||||
@shared_task
|
||||
def check_followup_engine():
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.services.followup_engine import FollowupEngine
|
||||
|
||||
async def _check():
|
||||
async with AsyncSessionLocal() as db:
|
||||
engine = FollowupEngine(db)
|
||||
result = await engine.scan_and_followup()
|
||||
logger.info(f"Followup engine check complete: {result}")
|
||||
return result
|
||||
|
||||
import asyncio
|
||||
return asyncio.run(_check())
|
||||
Reference in New Issue
Block a user