diff --git a/FNVdLW3ymU.txt b/FNVdLW3ymU.txt deleted file mode 100644 index 67bbbe077..000000000 --- a/FNVdLW3ymU.txt +++ /dev/null @@ -1 +0,0 @@ -42c7921ccaa00e1ec1933181a3005c1e \ No newline at end of file diff --git a/backend/app.pid b/backend/app.pid index 0b43c4966..f44ed988c 100644 --- a/backend/app.pid +++ b/backend/app.pid @@ -1 +1 @@ -56230 +91331 diff --git a/backend/app/api/pl/public_transport.py b/backend/app/api/pl/public_transport.py index 29ab9a0b2..0424fd5e9 100644 --- a/backend/app/api/pl/public_transport.py +++ b/backend/app/api/pl/public_transport.py @@ -58,19 +58,18 @@ from app.services.pl_product_display import ( load_crm_product_alias_maps, resolve_product_display_names, ) +from app.services.pl_public_transport_helpers import ( + CRM_PRODUCT_UNIT, + _history_status_label, + _normalize_main_plate, + _parse_history_date, + _resolve_driver_link_flags, + _resolve_unit_label, +) from app.utils.excel_export import load_dict_label_maps router = APIRouter(prefix="/public/pl/transport", tags=["运输计划-司机公共"]) -CRM_PRODUCT_UNIT = "crm_product_unit" - - -def _resolve_unit_label(unit_map: dict[str, str], unit_code: Optional[str]) -> str: - code = (unit_code or "").strip() - if not code: - return "吨" - return unit_map.get(code, code) - def _build_stop_product_reads( *, @@ -160,20 +159,6 @@ def _format_plan_date(value) -> Optional[str]: return str(value) -def _normalize_main_plate(value: Optional[str]) -> str: - return (value or "").strip() - - -def _parse_history_date(raw: Optional[str]) -> Optional[date]: - text = (raw or "").strip() - if not text: - return None - try: - return date.fromisoformat(text) - except ValueError: - return None - - def _as_date(value) -> Optional[date]: if value is None: return None @@ -188,14 +173,6 @@ def _history_matches_date(pickup_time, delivery_time, day: date) -> bool: return _as_date(pickup_time) == day or _as_date(delivery_time) == day -def _history_status_label(plan_voided: bool, plan_archived: bool) -> str: - if plan_voided: - return "已作废" - if plan_archived: - return "已归档" - return "进行中" - - def _build_history_stop_reads( db: Session, *, @@ -295,18 +272,6 @@ def _build_driver_url(token: str) -> str: return f"{origin}/#/public/pl/transport/{token}" -def _resolve_driver_link_flags( - plan: Optional[PlPlan], transport: PlTransport -) -> tuple[bool, bool]: - """返回 (作废, 归档)。""" - voided = ( - (plan is not None and plan.follow_status == "cancelled") - or transport.transport_status == "cancelled" - ) - archived = bool(plan is not None and plan.archive_status == "archived") - return voided, archived - - def _resolve_other_personnel(transport: PlTransport, plan: Optional[PlPlan]) -> list[PublicTransportPersonnelRead]: raw = transport.resource_snapshot or (plan.resource_snapshot if plan else None) snapshot = parse_resource_snapshot(raw) diff --git a/backend/app/api/pl/public_transport_site.py b/backend/app/api/pl/public_transport_site.py index 6ea1a637c..c85fe88b4 100644 --- a/backend/app/api/pl/public_transport_site.py +++ b/backend/app/api/pl/public_transport_site.py @@ -1,56 +1,21 @@ """运输计划 · 现场按段免登录公共 API(一车多卸)""" from __future__ import annotations -import os -from datetime import date, datetime -from decimal import Decimal from typing import Optional from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile -from sqlalchemy import and_, func, or_ from sqlalchemy.orm import Session -from app.api.pl.public_transport import ( - CRM_PRODUCT_UNIT, - _history_status_label, - _normalize_main_plate, - _parse_history_date, - _resolve_driver_link_flags, - _resolve_unit_label, -) -from app.core.attachment_biz import resolve_upload_folder -from app.core.config import settings from app.core.database import get_db from app.core.response import error_response, success_response -from app.core.storage import storage -from app.crud import oss as oss_svc -from app.models.pl_models import ( - PlPlan, - PlPlanDelivery, - PlPlanLoad, - PlPlanProduct, - PlPlanTransport, - PlTransport, +from app.models.pl_models import PlTransport +from app.schemas.pl.transport import PublicTransportSiteStopFeedback +from app.services.pl_site_plan_service import ( + build_site_history_page, + build_site_page_for_transport, + save_site_feedback_for_transport, + upload_site_attachment_for_transport, ) -from app.schemas.pl.transport import ( - PublicTransportHistoryPageRead, - PublicTransportHistoryProductRead, - PublicTransportHistoryRecordRead, - PublicTransportHistoryStopProductRead, - PublicTransportHistoryStopRead, - PublicTransportMultiStopSitePageRead, - PublicTransportSiteStopFeedback, -) -from app.services.pl_product_display import ( - load_crm_product_alias_maps, - resolve_product_display_names, -) -from app.services.pl_transport_stop_feedback_service import ( - build_public_stop_reads_for_segments, - load_site_stop_segments, - save_site_stop_feedback, -) -from app.utils.excel_export import load_dict_label_maps router = APIRouter(prefix="/public/pl/transport-site", tags=["运输计划-现场按段公共"]) @@ -69,168 +34,6 @@ def _get_transport_by_site_token(db: Session, token: str) -> PlTransport: return transport -def _format_plan_date(value) -> Optional[str]: - if value is None: - return None - if isinstance(value, datetime): - return value.strftime("%Y-%m-%d") - if isinstance(value, date): - return value.isoformat() - return str(value) - - -def _resolve_plan_flags(plan: Optional[PlPlan], transport: PlTransport) -> tuple[bool, bool]: - voided = ( - (plan is not None and plan.follow_status == "cancelled") - or transport.transport_status == "cancelled" - ) - archived = bool(plan is not None and plan.archive_status == "archived") - return voided, archived - - -def _build_public_page(db: Session, transport: PlTransport) -> PublicTransportMultiStopSitePageRead: - link = ( - db.query(PlPlanTransport) - .filter( - PlPlanTransport.transport_id == transport.id, - PlPlanTransport.del_flag == 0, - ) - .order_by(PlPlanTransport.create_time.desc()) - .first() - ) - plan = None - if link: - plan = ( - db.query(PlPlan) - .filter(PlPlan.id == link.plan_id, PlPlan.del_flag == 0) - .first() - ) - - plan_voided, plan_archived = _resolve_plan_flags(plan, transport) - stop_reads: list[dict] = [] - if plan: - segments = load_site_stop_segments( - db, - tenant_no=transport.tenant_no, - plan_id=plan.id, - transport_id=transport.id, - ) - stop_reads = build_public_stop_reads_for_segments( - db, - tenant_no=transport.tenant_no, - plan_id=plan.id, - segments=segments, - ) - - return PublicTransportMultiStopSitePageRead( - plan_no=plan.plan_no if plan else transport.transport_no, - plan_date=_format_plan_date(plan.plan_date if plan else None), - license_plate=transport.license_plate, - vehicle_no=transport.license_plate, - plan_voided=plan_voided, - plan_archived=plan_archived, - stops=stop_reads, - ) - - -def _build_site_url(token: str) -> str: - origin = (settings.PUBLIC_WEB_ORIGIN or "").rstrip("/") - return f"{origin}/#/public/pl/transport-site/{token}" - - -def _build_site_history_stop_reads( - db: Session, - *, - tenant_no: str, - plan: PlPlan, - transport: PlTransport, -) -> list[PublicTransportHistoryStopRead]: - plan_products = ( - db.query(PlPlanProduct) - .filter( - PlPlanProduct.plan_id == plan.id, - PlPlanProduct.tenant_no == tenant_no, - PlPlanProduct.del_flag == 0, - ) - .all() - ) - plan_products_by_id = {row.id: row for row in plan_products} - crm_product_ids = { - (row.product_id or "").strip() for row in plan_products if (row.product_id or "").strip() - } - standard_by_id, alias_options_by_id = load_crm_product_alias_maps( - db, tenant_no=tenant_no, product_ids=crm_product_ids - ) - unit_map = load_dict_label_maps(db, [CRM_PRODUCT_UNIT], tenant_no=tenant_no).get( - CRM_PRODUCT_UNIT, {} - ) - - load_row = ( - db.query(PlPlanLoad) - .filter( - PlPlanLoad.plan_id == plan.id, - PlPlanLoad.tenant_no == tenant_no, - PlPlanLoad.del_flag == 0, - ) - .first() - ) - delivery_rows = ( - db.query(PlPlanDelivery) - .filter( - PlPlanDelivery.plan_id == plan.id, - PlPlanDelivery.tenant_no == tenant_no, - PlPlanDelivery.del_flag == 0, - ) - .all() - ) - delivery_time_by_id = {row.id: row.delivery_time for row in delivery_rows} - load_time = load_row.load_time if load_row else None - - segments = load_site_stop_segments( - db, - tenant_no=tenant_no, - plan_id=plan.id, - transport_id=transport.id, - ) - if not segments: - return [] - - reads: list[PublicTransportHistoryStopRead] = [] - for segment in segments: - stop_time = load_time if segment.stop_type == "load" else delivery_time_by_id.get(segment.stop_id) - stop_products: list[PublicTransportHistoryStopProductRead] = [] - for row in segment.products: - plan_prod = plan_products_by_id.get(row.plan_product_id) - product_id = (row.product_id or (plan_prod.product_id if plan_prod else "") or "").strip() - display_name, _alias_name = resolve_product_display_names( - saved_product_name=row.product_name, - product_id=product_id, - standard_by_id=standard_by_id, - alias_options_by_id=alias_options_by_id, - ) - stop_products.append( - PublicTransportHistoryStopProductRead( - product_name=display_name or row.product_name or "—", - model=row.model, - package_spec=row.package_spec, - quantity=row.plan_qty, - unit=_resolve_unit_label(unit_map, row.unit), - ) - ) - reads.append( - PublicTransportHistoryStopRead( - stop_type=segment.stop_type, - seq=segment.seq, - label=segment.label, - customer_name=segment.customer_name, - address=segment.address, - stop_time=stop_time, - products=stop_products, - ) - ) - return reads - - @router.get("/{token}/history") def get_public_transport_site_history( token: str, @@ -239,152 +42,22 @@ def get_public_transport_site_history( db: Session = Depends(get_db), ): transport = _get_transport_by_site_token(db, token) - day = _parse_history_date(date) - if day is None: - return error_response(400, "日期格式无效,请使用 YYYY-MM-DD") - - kw = (q or "").strip() - query = ( - db.query(PlPlan, PlTransport) - .join( - PlPlanTransport, - and_( - PlPlanTransport.plan_id == PlPlan.id, - PlPlanTransport.del_flag == 0, - ), - ) - .join( - PlTransport, - and_( - PlTransport.id == PlPlanTransport.transport_id, - PlTransport.del_flag == 0, - ), - ) - .filter( - PlPlan.tenant_no == transport.tenant_no, - PlPlan.del_flag == 0, - PlPlan.transport_status == "arranged", - PlTransport.tenant_no == transport.tenant_no, - PlTransport.site_access_token.isnot(None), - PlTransport.site_access_token != "", - or_( - PlPlan.plan_date == day, - and_(PlPlan.pickup_time.isnot(None), func.date(PlPlan.pickup_time) == day), - and_(PlPlan.delivery_time.isnot(None), func.date(PlPlan.delivery_time) == day), - ), - ) - ) - if kw: - like = f"%{kw}%" - product_match = ( - db.query(PlPlanProduct.id) - .filter( - PlPlanProduct.plan_id == PlPlan.id, - PlPlanProduct.del_flag == 0, - PlPlanProduct.product_name.like(like), - ) - .exists() - ) - query = query.filter(or_(PlTransport.license_plate.like(like), product_match)) - - rows = query.order_by( - PlPlan.pickup_time.desc(), - PlPlan.plan_date.desc(), - PlPlan.create_time.desc(), - ).all() - - seen_plan: set[str] = set() - plan_rows: list[tuple[PlPlan, PlTransport, list[PublicTransportHistoryStopRead]]] = [] - for plan, tr in rows: - if plan.id in seen_plan: - continue - site_tok = (tr.site_access_token or "").strip() - if not site_tok: - continue - site_stops = _build_site_history_stop_reads( - db, tenant_no=transport.tenant_no, plan=plan, transport=tr - ) - if not site_stops: - continue - seen_plan.add(plan.id) - plan_rows.append((plan, tr, site_stops)) - - products_by_plan: dict[str, list[PublicTransportHistoryProductRead]] = {} - if plan_rows: - plan_ids = [plan.id for plan, _, _ in plan_rows] - product_rows = ( - db.query(PlPlanProduct) - .filter( - PlPlanProduct.plan_id.in_(plan_ids), - PlPlanProduct.del_flag == 0, - ) - .order_by(PlPlanProduct.order_num.asc(), PlPlanProduct.create_time.asc()) - .all() - ) - crm_product_ids = { - (p.product_id or "").strip() for p in product_rows if (p.product_id or "").strip() - } - standard_by_id, alias_options_by_id = load_crm_product_alias_maps( - db, tenant_no=transport.tenant_no, product_ids=crm_product_ids - ) - unit_map = load_dict_label_maps( - db, [CRM_PRODUCT_UNIT], tenant_no=transport.tenant_no - ).get(CRM_PRODUCT_UNIT, {}) - for prod in product_rows: - product_id = (prod.product_id or "").strip() - display_name, _alias_name = resolve_product_display_names( - saved_product_name=prod.product_name, - product_id=product_id, - standard_by_id=standard_by_id, - alias_options_by_id=alias_options_by_id, - ) - products_by_plan.setdefault(prod.plan_id, []).append( - PublicTransportHistoryProductRead( - product_name=display_name or prod.product_name or "—", - model=prod.model, - package_spec=prod.package_spec, - quantity=prod.quantity or prod.inbound_estimate_qty or prod.outbound_estimate_qty, - unit=_resolve_unit_label(unit_map, prod.unit), - ) - ) - - records: list[PublicTransportHistoryRecordRead] = [] - for plan, tr, site_stops in plan_rows: - voided, archived = _resolve_driver_link_flags(plan, tr) - site_tok = (tr.site_access_token or "").strip() - records.append( - PublicTransportHistoryRecordRead( - plan_id=plan.id, - plan_no=plan.plan_no or "", - transport_id=tr.id, - site_access_token=site_tok or None, - site_url=_build_site_url(site_tok) if site_tok else None, - license_plate=tr.license_plate, - pickup_time=plan.pickup_time, - delivery_time=plan.delivery_time, - pickup_address=plan.pickup_address, - destination_address=plan.destination_address or plan.destination_name, - products=products_by_plan.get(plan.id) or [], - stops=site_stops, - plan_voided=voided, - plan_archived=archived, - status_label=_history_status_label(voided, archived), - ) - ) - - page = PublicTransportHistoryPageRead( - license_plate=_normalize_main_plate(transport.license_plate) or None, - date=day.isoformat(), - keyword=kw or None, - records=records, + page, err = build_site_history_page( + db, + tenant_no=transport.tenant_no, + date_str=date, + keyword=q, + license_plate_hint=transport.license_plate, ) + if err: + return error_response(400, err) return success_response(page.model_dump(by_alias=True)) @router.get("/{token}") def get_public_transport_site(token: str, db: Session = Depends(get_db)): transport = _get_transport_by_site_token(db, token) - page = _build_public_page(db, transport) + page = build_site_page_for_transport(db, transport) return success_response(page.model_dump(by_alias=True)) @@ -395,68 +68,21 @@ def save_public_site_stop_feedback( db: Session = Depends(get_db), ): transport = _get_transport_by_site_token(db, token) - page = _build_public_page(db, transport) - if page.plan_voided: - return error_response(400, "运输计划已作废,无法提交回执") - if page.plan_archived: - return error_response(400, "运输计划已归档,无法提交回执") - - link = ( - db.query(PlPlanTransport) - .filter( - PlPlanTransport.transport_id == transport.id, - PlPlanTransport.del_flag == 0, - ) - .order_by(PlPlanTransport.create_time.desc()) - .first() - ) - if not link or not link.plan_id: - return error_response(400, "未找到关联计划,无法保存段回传") - - stop_type = (payload.stop_type or "").strip() - stop_id = (payload.stop_id or "").strip() - if stop_type not in ("load", "delivery") or not stop_id: - return error_response(400, "停靠段参数无效") - - segments = load_site_stop_segments( + ok, err = save_site_feedback_for_transport( db, - tenant_no=transport.tenant_no, - plan_id=link.plan_id, - transport_id=transport.id, + transport, + stop_type=payload.stop_type, + stop_id=payload.stop_id, + quantity=payload.quantity, + attachment_urls=payload.attachment_urls, + remark=payload.remark, ) - segment_map = {(item.stop_type, item.stop_id): item for item in segments} - segment = segment_map.get((stop_type, stop_id)) - if not segment: - return error_response(400, "该段不在现场回传范围内") - - try: - save_site_stop_feedback( - db, - tenant_no=transport.tenant_no, - transport_id=transport.id, - plan_id=link.plan_id, - stop_type=stop_type, - stop_id=stop_id, - business_type=segment.business_type or "入库", - quantity=payload.quantity, - attachment_urls=payload.attachment_urls, - remark=payload.remark, - ) - except ValueError as exc: - return error_response(400, str(exc)) - + if not ok: + return error_response(400, err or "保存失败") db.commit() return success_response({"success": True, "message": "保存成功"}) -def _format_file_size(size_bytes: int) -> str: - if size_bytes < 1024: - return f"{size_bytes} B" - if size_bytes < 1024 * 1024: - return f"{size_bytes / 1024:.1f} KB" - return f"{size_bytes / (1024 * 1024):.1f} MB" - - @router.post("/{token}/attachments") def upload_public_site_attachment( token: str, @@ -465,49 +91,10 @@ def upload_public_site_attachment( db: Session = Depends(get_db), ): transport = _get_transport_by_site_token(db, token) - page = _build_public_page(db, transport) - if page.plan_voided: - return error_response(400, "运输计划已作废,无法上传附件") - if page.plan_archived: - return error_response(400, "运输计划已归档,无法上传附件") - if not file or not file.filename: - return error_response(400, "请选择文件") - - try: - effective_folder = resolve_upload_folder( - folder=folder or "", biz_type="pl_transport_site_stop_feedback" - ) - file_content = file.file.read() - full_url = storage.upload_file(file_content, file.filename, effective_folder) - base_url = settings.MINIO_BASE_URL.rstrip("/") if settings.MINIO_BASE_URL else "" - if base_url and full_url.startswith(base_url + "/"): - file_url = full_url[len(base_url) + 1 :] - else: - file_url = full_url - - record = oss_svc.create_oss( - db, - tenant_no=transport.tenant_no, - url=file_url, - original_name=file.filename, - file_suffix=os.path.splitext(file.filename)[1].lstrip(".") if file.filename else "", - file_size=len(file_content), - file_type=file.content_type or "", - biz_type="pl_transport_site_stop_feedback", - user_id=None, - ext1=effective_folder or None, - ) - db.commit() - return success_response( - { - "id": record.id, - "fileName": record.original_name, - "fileUrl": record.url, - "fileSize": record.file_size, - "fileSizeDisplay": _format_file_size(record.file_size or 0), - "fileType": record.file_type, - } - ) - except Exception as exc: - db.rollback() - return error_response(500, f"上传失败: {str(exc)}") + data, err = upload_site_attachment_for_transport( + db, transport, file=file, folder=folder + ) + if err: + code, msg = err + return error_response(code, msg) + return success_response(data) diff --git a/backend/app/api/pl/site_plan.py b/backend/app/api/pl/site_plan.py new file mode 100644 index 000000000..fd93951d1 --- /dev/null +++ b/backend/app/api/pl/site_plan.py @@ -0,0 +1,93 @@ +"""小程序/门户登录态 · 运输计划单(现场记录/回传)""" +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.db_auth import DbUserContext, get_db_current_user +from app.core.response import error_response, success_response +from app.core.tenant_context import get_tenant_no +from app.schemas.pl.transport import PublicTransportSiteStopFeedback +from app.services import pl_site_plan_service as site_svc + +router = APIRouter(prefix="/site-plan", tags=["运输计划-现场计划单"]) + + +@router.get("/history") +def get_site_plan_history( + date: str = Query(..., description="筛选日 YYYY-MM-DD"), + q: Optional[str] = Query(None, description="车牌号/产品名称模糊"), + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +): + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + page, err = site_svc.build_site_history_page( + db, tenant_no=tenant_no, date_str=date, keyword=q + ) + if err: + return error_response(400, err) + return success_response(page.model_dump(by_alias=True)) + + +@router.get("/{plan_id}") +def get_site_plan_detail( + plan_id: str, + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +): + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + page, err = site_svc.build_site_page_for_plan( + db, tenant_no=tenant_no, plan_id=plan_id + ) + if err or page is None: + raise HTTPException(status_code=404, detail=err or "计划不存在或无现场链接") + return success_response(page.model_dump(by_alias=True)) + + +@router.post("/{plan_id}/feedback") +def save_site_plan_feedback( + plan_id: str, + payload: PublicTransportSiteStopFeedback, + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +): + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + ok, msg = site_svc.save_site_feedback_for_plan( + db, + tenant_no=tenant_no, + plan_id=plan_id, + stop_type=payload.stop_type, + stop_id=payload.stop_id, + quantity=payload.quantity, + attachment_urls=payload.attachment_urls, + remark=payload.remark, + ) + if not ok: + return error_response(400, msg or "保存失败") + db.commit() + return success_response({"success": True, "message": "保存成功"}) + + +@router.post("/{plan_id}/attachments") +def upload_site_plan_attachment( + plan_id: str, + file: UploadFile = File(...), + folder: Optional[str] = Form(""), + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +): + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + data, err = site_svc.upload_site_attachment_for_plan( + db, + tenant_no=tenant_no, + plan_id=plan_id, + file=file, + folder=folder or "", + ) + if err: + code, msg = err + return error_response(code, msg) + return success_response(data) diff --git a/backend/app/api/pl/vehicle_fuel.py b/backend/app/api/pl/vehicle_fuel.py new file mode 100644 index 000000000..dd6c36aa3 --- /dev/null +++ b/backend/app/api/pl/vehicle_fuel.py @@ -0,0 +1,241 @@ +"""车辆加油登记 API""" +from datetime import date, datetime +from typing import Any, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.db_auth import DbUserContext, get_db_current_user +from app.core.tenant_context import get_tenant_no +from app.schemas.pl.vehicle_fuel import ( + VehicleFuelRegisterCreate, + VehicleFuelRegisterListResponse, + VehicleFuelRegisterRead, + VehicleFuelRegisterUpdate, +) +from app.services import pl_vehicle_fuel_service as svc +from app.utils.excel_export import ( + EXPORT_MAX_ROWS, + ExportColumn, + ExportValidationError, + export_rows_to_xlsx_response, + filter_export_columns, +) + +router = APIRouter(prefix="/vehicle-fuel-register", tags=["运输计划-加油登记"]) + +FUEL_CATEGORY_LABELS = {"bailian": "百链", "gas_station": "加油站"} +FUEL_STATUS_LABELS = {"normal": "正常", "cancelled": "已作废"} + + +def _merge_query(request: Request, **known) -> dict: + out = {k: v for k, v in known.items() if v is not None and v != ""} + for k, v in request.query_params.multi_items(): + if k in ("page", "pageSize"): + continue + if k not in out and v not in (None, ""): + out[k] = v + return out + + +def _param_get(params: dict, *keys: str) -> Any: + for key in keys: + if key in params and params[key] not in (None, ""): + return params[key] + return None + + +def _as_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if value is None: + return False + return str(value).strip().lower() in {"1", "true", "yes", "y"} + + +def _body_to_params(body: BaseModel) -> dict: + data = body.model_dump(by_alias=True, exclude_none=True) + for key in ("ids", "columns"): + data.pop(key, None) + extra = getattr(body, "model_extra", None) or {} + for key, value in extra.items(): + if key not in data and value not in (None, ""): + data[key] = value + return data + + +def _list_kwargs(params: dict) -> dict: + return dict( + q=_param_get(params, "q"), + register_no=_param_get(params, "registerNo", "register_no"), + license_plate=_param_get(params, "licensePlate", "license_plate"), + fuel_category=_param_get(params, "fuelCategory", "fuel_category"), + fuel_category_in=_param_get(params, "fuelCategoryIn", "fuel_category_in"), + status=_param_get(params, "status"), + status_in=_param_get(params, "statusIn", "status_in"), + register_date_from=_param_get(params, "registerDateFrom", "register_date_from"), + register_date_to=_param_get(params, "registerDateTo", "register_date_to"), + registrant_id=_param_get(params, "registrantId", "registrant_id"), + mine_only=_as_bool(_param_get(params, "mineOnly", "mine_only")), + ) + + +@router.get("/", response_model=VehicleFuelRegisterListResponse) +def list_registers( + request: Request, + q: Optional[str] = Query(default=None), + fuel_category: Optional[str] = Query(default=None, alias="fuelCategory"), + status: Optional[str] = Query(default=None), + register_date_from: Optional[date] = Query(default=None, alias="registerDateFrom"), + register_date_to: Optional[date] = Query(default=None, alias="registerDateTo"), + registrant_id: Optional[str] = Query(default=None, alias="registrantId"), + mine_only: Optional[bool] = Query(default=None, alias="mineOnly"), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=500, alias="pageSize"), + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +) -> VehicleFuelRegisterListResponse: + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + params = _merge_query( + request, + q=q, + fuelCategory=fuel_category, + status=status, + registerDateFrom=register_date_from, + registerDateTo=register_date_to, + registrantId=registrant_id, + mineOnly=mine_only, + ) + items, total = svc.list_registers( + db, + tenant_no, + user, + **_list_kwargs(params), + page=page, + page_size=page_size, + ) + return VehicleFuelRegisterListResponse( + items=items, total=total, page=page, pageSize=page_size + ) + + +FUEL_EXPORT_COLUMNS = [ + ExportColumn(title="登记单号", field="registerNo"), + ExportColumn(title="加油类别", field="fuelCategory"), + ExportColumn(title="日期", field="registerDate"), + ExportColumn(title="登记人", field="registrantName"), + ExportColumn(title="车牌号", field="licensePlate"), + ExportColumn(title="期初公里数", field="odometerKm"), + ExportColumn(title="加油升数", field="fuelLiters"), + ExportColumn(title="状态", field="status"), + ExportColumn(title="备注", field="remark"), + ExportColumn(title="创建时间", field="createTime"), +] + + +class FuelRegisterExportBody(BaseModel): + q: Optional[str] = None + fuel_category: Optional[str] = Field(default=None, alias="fuelCategory") + status: Optional[str] = None + register_date_from: Optional[date] = Field(default=None, alias="registerDateFrom") + register_date_to: Optional[date] = Field(default=None, alias="registerDateTo") + registrant_id: Optional[str] = Field(default=None, alias="registrantId") + mine_only: Optional[bool] = Field(default=None, alias="mineOnly") + ids: Optional[List[str]] = None + columns: Optional[List[str]] = None + model_config = {"populate_by_name": True, "extra": "allow"} + + +@router.post("/export") +def export_registers( + body: FuelRegisterExportBody, + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +): + try: + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + params = _body_to_params(body) + items, _ = svc.list_registers( + db, + tenant_no, + user, + **_list_kwargs(params), + ids=body.ids, + page=1, + page_size=EXPORT_MAX_ROWS, + max_page_size=EXPORT_MAX_ROWS, + ) + rows = [] + for item in items: + data = item.model_dump(by_alias=True) + data["fuelCategory"] = FUEL_CATEGORY_LABELS.get( + data.get("fuelCategory") or "", data.get("fuelCategory") + ) + data["status"] = FUEL_STATUS_LABELS.get( + data.get("status") or "", data.get("status") + ) + for k, v in list(data.items()): + if hasattr(v, "isoformat"): + data[k] = str(v)[:19] + rows.append(data) + + cols = filter_export_columns(FUEL_EXPORT_COLUMNS, body.columns) + date_str = datetime.now().strftime("%Y%m%d") + return export_rows_to_xlsx_response( + db, + tenant_no=tenant_no, + sheet_title="加油登记", + columns=cols, + rows=rows, + filename_stem=f"加油登记_{date_str}", + ) + except ExportValidationError as e: + raise HTTPException(status_code=400, detail=str(e)) from e + + +@router.get("/{id}", response_model=VehicleFuelRegisterRead) +def get_register( + id: str, + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +) -> VehicleFuelRegisterRead: + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + return svc.get_register(db, tenant_no, user, id) + + +@router.post("/", response_model=VehicleFuelRegisterRead, status_code=status.HTTP_201_CREATED) +def create_register( + payload: VehicleFuelRegisterCreate, + mine_only: bool = Query(default=False, alias="mineOnly"), + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +) -> VehicleFuelRegisterRead: + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + return svc.create_register(db, tenant_no, user, payload, force_self=mine_only) + + +@router.put("/{id}", response_model=VehicleFuelRegisterRead) +def update_register( + id: str, + payload: VehicleFuelRegisterUpdate, + mine_only: bool = Query(default=False, alias="mineOnly"), + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +) -> VehicleFuelRegisterRead: + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + return svc.update_register( + db, tenant_no, user, id, payload, force_self=mine_only + ) + + +@router.post("/{id}/cancel", response_model=VehicleFuelRegisterRead) +def cancel_register( + id: str, + mine_only: bool = Query(default=False, alias="mineOnly"), + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +) -> VehicleFuelRegisterRead: + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + return svc.cancel_register(db, tenant_no, user, id, force_self=mine_only) diff --git a/backend/app/models/pl_models.py b/backend/app/models/pl_models.py index 4e91a3ce0..483f95193 100644 --- a/backend/app/models/pl_models.py +++ b/backend/app/models/pl_models.py @@ -1291,6 +1291,32 @@ class PlDocumentReminder(Base): del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记:0-正常 1-已删除") +class PlVehicleFuelRegister(Base): + """车辆加油登记 pl_vehicle_fuel_register""" + + __tablename__ = "pl_vehicle_fuel_register" + + id = Column(String(64), primary_key=True, comment="主键ID") + register_no = Column(String(32), nullable=False, unique=True, comment="登记单号") + fuel_category = Column(String(20), nullable=False, comment="加油类别 bailian/gas_station") + register_date = Column(Date, nullable=False, comment="日期") + registrant_id = Column(String(64), nullable=False, comment="登记人用户ID") + license_plate = Column(String(20), nullable=False, comment="车牌号") + odometer_km = Column(DECIMAL(10, 1), nullable=True, comment="期初公里数") + fuel_liters = Column(DECIMAL(10, 2), nullable=False, comment="加油升数") + attachment_urls = Column(String(2000), nullable=True, comment="附件URL") + status = Column(String(20), nullable=False, default="normal", comment="状态") + tenant_no = Column(String(64), nullable=False, default="shanxia2.0", comment="租户编号") + order_num = Column(Integer, nullable=True, comment="排序号") + remark = Column(String(512), nullable=True, comment="备注") + create_by = Column(String(64), nullable=True, comment="创建人id") + create_dept_id = Column(String(64), nullable=False, default="", comment="创建人部门ID") + create_time = Column(DateTime, nullable=False, default=datetime.now, comment="创建时间") + update_by = Column(String(64), nullable=True, comment="更新人id") + update_time = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now, comment="更新时间") + del_flag = Column(SmallInteger, nullable=False, default=0, comment="删除标记:0-正常 1-已删除") + + class PlVehicleRechargeApply(Base): """车辆ETC/加油充值申请 pl_vehicle_recharge_apply""" diff --git a/backend/app/schemas/pl/vehicle_fuel.py b/backend/app/schemas/pl/vehicle_fuel.py new file mode 100644 index 000000000..06d7546a0 --- /dev/null +++ b/backend/app/schemas/pl/vehicle_fuel.py @@ -0,0 +1,75 @@ +"""车辆加油登记 Schema""" +from datetime import date, datetime +from decimal import Decimal +from typing import Optional + +from pydantic import BaseModel, Field, field_validator + +FUEL_CATEGORIES = {"bailian", "gas_station"} +FUEL_STATUSES = {"normal", "cancelled"} + + +class VehicleFuelRegisterCreate(BaseModel): + fuel_category: str = Field(..., alias="fuelCategory") + register_date: date = Field(..., alias="registerDate") + registrant_id: Optional[str] = Field(default=None, alias="registrantId") + license_plate: str = Field(..., alias="licensePlate") + odometer_km: Optional[Decimal] = Field(default=None, alias="odometerKm") + fuel_liters: Decimal = Field(..., alias="fuelLiters") + attachment_urls: Optional[str] = Field(default=None, alias="attachmentUrls") + remark: Optional[str] = None + + model_config = {"populate_by_name": True} + + @field_validator("fuel_category") + @classmethod + def validate_fuel_category(cls, v: str) -> str: + if v not in FUEL_CATEGORIES: + raise ValueError("加油类别须为百链或加油站") + return v + + @field_validator("fuel_liters") + @classmethod + def validate_fuel_liters(cls, v: Decimal) -> Decimal: + if v is None or v <= 0: + raise ValueError("加油升数须大于 0") + return v + + @field_validator("odometer_km") + @classmethod + def validate_odometer_km(cls, v: Optional[Decimal]) -> Optional[Decimal]: + if v is not None and v < 0: + raise ValueError("期初公里数不能为负数") + return v + + +class VehicleFuelRegisterUpdate(VehicleFuelRegisterCreate): + pass + + +class VehicleFuelRegisterRead(BaseModel): + id: str + register_no: str = Field(..., alias="registerNo") + fuel_category: str = Field(..., alias="fuelCategory") + register_date: date = Field(..., alias="registerDate") + registrant_id: str = Field(..., alias="registrantId") + registrant_name: Optional[str] = Field(default=None, alias="registrantName") + license_plate: str = Field(..., alias="licensePlate") + odometer_km: Optional[Decimal] = Field(default=None, alias="odometerKm") + fuel_liters: Decimal = Field(..., alias="fuelLiters") + attachment_urls: Optional[str] = Field(default=None, alias="attachmentUrls") + status: str + remark: Optional[str] = None + create_time: Optional[datetime] = Field(default=None, alias="createTime") + update_time: Optional[datetime] = Field(default=None, alias="updateTime") + + model_config = {"populate_by_name": True} + + +class VehicleFuelRegisterListResponse(BaseModel): + items: list[VehicleFuelRegisterRead] + total: int + page: int + page_size: int = Field(..., alias="pageSize") + + model_config = {"populate_by_name": True} diff --git a/backend/app/services/pl_public_transport_helpers.py b/backend/app/services/pl_public_transport_helpers.py new file mode 100644 index 000000000..0a9e0304b --- /dev/null +++ b/backend/app/services/pl_public_transport_helpers.py @@ -0,0 +1,50 @@ +"""运输计划公共页共享纯函数(司机/现场 history 等)。""" +from __future__ import annotations + +from datetime import date +from typing import Optional + +from app.models.pl_models import PlPlan, PlTransport + +CRM_PRODUCT_UNIT = "crm_product_unit" + + +def _resolve_unit_label(unit_map: dict[str, str], unit_code: Optional[str]) -> str: + code = (unit_code or "").strip() + if not code: + return "吨" + return unit_map.get(code, code) + + +def _normalize_main_plate(value: Optional[str]) -> str: + return (value or "").strip() + + +def _parse_history_date(raw: Optional[str]) -> Optional[date]: + text = (raw or "").strip() + if not text: + return None + try: + return date.fromisoformat(text) + except ValueError: + return None + + +def _history_status_label(plan_voided: bool, plan_archived: bool) -> str: + if plan_voided: + return "已作废" + if plan_archived: + return "已归档" + return "进行中" + + +def _resolve_driver_link_flags( + plan: Optional[PlPlan], transport: PlTransport +) -> tuple[bool, bool]: + """返回 (作废, 归档)。""" + voided = ( + (plan is not None and plan.follow_status == "cancelled") + or transport.transport_status == "cancelled" + ) + archived = bool(plan is not None and plan.archive_status == "archived") + return voided, archived diff --git a/backend/app/services/pl_site_plan_service.py b/backend/app/services/pl_site_plan_service.py new file mode 100644 index 000000000..10317f88f --- /dev/null +++ b/backend/app/services/pl_site_plan_service.py @@ -0,0 +1,591 @@ +"""运输计划 · 现场按段页(history / page / feedback / upload)共享逻辑""" +from __future__ import annotations + +import os +from datetime import date, datetime +from decimal import Decimal +from typing import Any, Optional, Tuple + +from fastapi import UploadFile +from sqlalchemy import and_, func, or_ +from sqlalchemy.orm import Session + +from app.core.attachment_biz import resolve_upload_folder +from app.core.config import settings +from app.core.storage import storage +from app.crud import oss as oss_svc +from app.models.pl_models import ( + PlPlan, + PlPlanDelivery, + PlPlanLoad, + PlPlanProduct, + PlPlanTransport, + PlTransport, +) +from app.schemas.pl.transport import ( + PublicTransportHistoryPageRead, + PublicTransportHistoryProductRead, + PublicTransportHistoryRecordRead, + PublicTransportHistoryStopProductRead, + PublicTransportHistoryStopRead, + PublicTransportMultiStopSitePageRead, +) +from app.services.pl_product_display import ( + load_crm_product_alias_maps, + resolve_product_display_names, +) +from app.services.pl_public_transport_helpers import ( + CRM_PRODUCT_UNIT, + _history_status_label, + _normalize_main_plate, + _parse_history_date, + _resolve_driver_link_flags, + _resolve_unit_label, +) +from app.services.pl_transport_stop_feedback_service import ( + build_public_stop_reads_for_segments, + build_transport_site_url, + load_site_stop_segments, + save_site_stop_feedback, +) +from app.utils.excel_export import load_dict_label_maps + + +def plan_matches_history_day(plan: PlPlan, day: date) -> bool: + """Mirrors SQL day filters in build_site_history_page for unit tests.""" + if plan.plan_date == day: + return True + if plan.pickup_time is not None: + pt = plan.pickup_time + if (pt.date() if isinstance(pt, datetime) else pt) == day: + return True + if plan.delivery_time is not None: + dt = plan.delivery_time + if (dt.date() if isinstance(dt, datetime) else dt) == day: + return True + return False + + +def resolve_transport_for_plan( + db: Session, *, tenant_no: str, plan_id: str +) -> Tuple[Optional[PlPlan], Optional[PlTransport]]: + """本租户计划 + 关联运输(须有有效 site_access_token)。找不到返回 (None, None)。""" + plan = ( + db.query(PlPlan) + .filter( + PlPlan.id == plan_id, + PlPlan.tenant_no == tenant_no, + PlPlan.del_flag == 0, + ) + .first() + ) + if not plan: + return None, None + link = ( + db.query(PlPlanTransport) + .filter( + PlPlanTransport.plan_id == plan.id, + PlPlanTransport.del_flag == 0, + ) + .order_by(PlPlanTransport.create_time.desc()) + .first() + ) + if not link or not link.transport_id: + return plan, None + transport = ( + db.query(PlTransport) + .filter( + PlTransport.id == link.transport_id, + PlTransport.tenant_no == tenant_no, + PlTransport.del_flag == 0, + PlTransport.site_access_token.isnot(None), + PlTransport.site_access_token != "", + ) + .first() + ) + return plan, transport + + +def _format_plan_date(value) -> Optional[str]: + if value is None: + return None + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d") + if isinstance(value, date): + return value.isoformat() + return str(value) + + +def _format_file_size(size_bytes: int) -> str: + if size_bytes < 1024: + return f"{size_bytes} B" + if size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + return f"{size_bytes / (1024 * 1024):.1f} MB" + + +def build_site_page_for_transport( + db: Session, transport: PlTransport +) -> PublicTransportMultiStopSitePageRead: + link = ( + db.query(PlPlanTransport) + .filter( + PlPlanTransport.transport_id == transport.id, + PlPlanTransport.del_flag == 0, + ) + .order_by(PlPlanTransport.create_time.desc()) + .first() + ) + plan = None + if link: + plan = ( + db.query(PlPlan) + .filter(PlPlan.id == link.plan_id, PlPlan.del_flag == 0) + .first() + ) + + plan_voided, plan_archived = _resolve_driver_link_flags(plan, transport) + stop_reads: list[dict] = [] + if plan: + segments = load_site_stop_segments( + db, + tenant_no=transport.tenant_no, + plan_id=plan.id, + transport_id=transport.id, + ) + stop_reads = build_public_stop_reads_for_segments( + db, + tenant_no=transport.tenant_no, + plan_id=plan.id, + segments=segments, + ) + + return PublicTransportMultiStopSitePageRead( + plan_no=plan.plan_no if plan else transport.transport_no, + plan_date=_format_plan_date(plan.plan_date if plan else None), + license_plate=transport.license_plate, + vehicle_no=transport.license_plate, + plan_voided=plan_voided, + plan_archived=plan_archived, + stops=stop_reads, + ) + + +def build_site_page_for_plan( + db: Session, *, tenant_no: str, plan_id: str +) -> tuple[Optional[PublicTransportMultiStopSitePageRead], Optional[str]]: + plan, transport = resolve_transport_for_plan(db, tenant_no=tenant_no, plan_id=plan_id) + if not plan: + return None, "计划不存在" + if not transport: + return None, "未找到关联运输或现场链接无效" + return build_site_page_for_transport(db, transport), None + + +def _build_site_history_stop_reads( + db: Session, + *, + tenant_no: str, + plan: PlPlan, + transport: PlTransport, +) -> list[PublicTransportHistoryStopRead]: + plan_products = ( + db.query(PlPlanProduct) + .filter( + PlPlanProduct.plan_id == plan.id, + PlPlanProduct.tenant_no == tenant_no, + PlPlanProduct.del_flag == 0, + ) + .all() + ) + plan_products_by_id = {row.id: row for row in plan_products} + crm_product_ids = { + (row.product_id or "").strip() for row in plan_products if (row.product_id or "").strip() + } + standard_by_id, alias_options_by_id = load_crm_product_alias_maps( + db, tenant_no=tenant_no, product_ids=crm_product_ids + ) + unit_map = load_dict_label_maps(db, [CRM_PRODUCT_UNIT], tenant_no=tenant_no).get( + CRM_PRODUCT_UNIT, {} + ) + + load_row = ( + db.query(PlPlanLoad) + .filter( + PlPlanLoad.plan_id == plan.id, + PlPlanLoad.tenant_no == tenant_no, + PlPlanLoad.del_flag == 0, + ) + .first() + ) + delivery_rows = ( + db.query(PlPlanDelivery) + .filter( + PlPlanDelivery.plan_id == plan.id, + PlPlanDelivery.tenant_no == tenant_no, + PlPlanDelivery.del_flag == 0, + ) + .all() + ) + delivery_time_by_id = {row.id: row.delivery_time for row in delivery_rows} + load_time = load_row.load_time if load_row else None + + segments = load_site_stop_segments( + db, + tenant_no=tenant_no, + plan_id=plan.id, + transport_id=transport.id, + ) + if not segments: + return [] + + reads: list[PublicTransportHistoryStopRead] = [] + for segment in segments: + stop_time = load_time if segment.stop_type == "load" else delivery_time_by_id.get(segment.stop_id) + stop_products: list[PublicTransportHistoryStopProductRead] = [] + for row in segment.products: + plan_prod = plan_products_by_id.get(row.plan_product_id) + product_id = (row.product_id or (plan_prod.product_id if plan_prod else "") or "").strip() + display_name, _alias_name = resolve_product_display_names( + saved_product_name=row.product_name, + product_id=product_id, + standard_by_id=standard_by_id, + alias_options_by_id=alias_options_by_id, + ) + stop_products.append( + PublicTransportHistoryStopProductRead( + product_name=display_name or row.product_name or "—", + model=row.model, + package_spec=row.package_spec, + quantity=row.plan_qty, + unit=_resolve_unit_label(unit_map, row.unit), + ) + ) + reads.append( + PublicTransportHistoryStopRead( + stop_type=segment.stop_type, + seq=segment.seq, + label=segment.label, + customer_name=segment.customer_name, + address=segment.address, + stop_time=stop_time, + products=stop_products, + ) + ) + return reads + + +def build_site_history_page( + db: Session, + *, + tenant_no: str, + date_str: str, + keyword: Optional[str] = None, + license_plate_hint: Optional[str] = None, +) -> tuple[Optional[PublicTransportHistoryPageRead], Optional[str]]: + day = _parse_history_date(date_str) + if day is None: + return None, "日期格式无效,请使用 YYYY-MM-DD" + + kw = (keyword or "").strip() + query = ( + db.query(PlPlan, PlTransport) + .join( + PlPlanTransport, + and_( + PlPlanTransport.plan_id == PlPlan.id, + PlPlanTransport.del_flag == 0, + ), + ) + .join( + PlTransport, + and_( + PlTransport.id == PlPlanTransport.transport_id, + PlTransport.del_flag == 0, + ), + ) + .filter( + PlPlan.tenant_no == tenant_no, + PlPlan.del_flag == 0, + PlPlan.transport_status == "arranged", + PlTransport.tenant_no == tenant_no, + PlTransport.site_access_token.isnot(None), + PlTransport.site_access_token != "", + or_( + PlPlan.plan_date == day, + and_(PlPlan.pickup_time.isnot(None), func.date(PlPlan.pickup_time) == day), + and_(PlPlan.delivery_time.isnot(None), func.date(PlPlan.delivery_time) == day), + ), + ) + ) + if kw: + like = f"%{kw}%" + product_match = ( + db.query(PlPlanProduct.id) + .filter( + PlPlanProduct.plan_id == PlPlan.id, + PlPlanProduct.del_flag == 0, + PlPlanProduct.product_name.like(like), + ) + .exists() + ) + query = query.filter(or_(PlTransport.license_plate.like(like), product_match)) + + rows = query.order_by( + PlPlan.pickup_time.desc(), + PlPlan.plan_date.desc(), + PlPlan.create_time.desc(), + ).all() + + seen_plan: set[str] = set() + plan_rows: list[tuple[PlPlan, PlTransport, list[PublicTransportHistoryStopRead]]] = [] + for plan, tr in rows: + if plan.id in seen_plan: + continue + site_tok = (tr.site_access_token or "").strip() + if not site_tok: + continue + site_stops = _build_site_history_stop_reads( + db, tenant_no=tenant_no, plan=plan, transport=tr + ) + if not site_stops: + continue + seen_plan.add(plan.id) + plan_rows.append((plan, tr, site_stops)) + + products_by_plan: dict[str, list[PublicTransportHistoryProductRead]] = {} + if plan_rows: + plan_ids = [plan.id for plan, _, _ in plan_rows] + product_rows = ( + db.query(PlPlanProduct) + .filter( + PlPlanProduct.plan_id.in_(plan_ids), + PlPlanProduct.del_flag == 0, + ) + .order_by(PlPlanProduct.order_num.asc(), PlPlanProduct.create_time.asc()) + .all() + ) + crm_product_ids = { + (p.product_id or "").strip() for p in product_rows if (p.product_id or "").strip() + } + standard_by_id, alias_options_by_id = load_crm_product_alias_maps( + db, tenant_no=tenant_no, product_ids=crm_product_ids + ) + unit_map = load_dict_label_maps(db, [CRM_PRODUCT_UNIT], tenant_no=tenant_no).get( + CRM_PRODUCT_UNIT, {} + ) + for prod in product_rows: + product_id = (prod.product_id or "").strip() + display_name, _alias_name = resolve_product_display_names( + saved_product_name=prod.product_name, + product_id=product_id, + standard_by_id=standard_by_id, + alias_options_by_id=alias_options_by_id, + ) + products_by_plan.setdefault(prod.plan_id, []).append( + PublicTransportHistoryProductRead( + product_name=display_name or prod.product_name or "—", + model=prod.model, + package_spec=prod.package_spec, + quantity=prod.quantity or prod.inbound_estimate_qty or prod.outbound_estimate_qty, + unit=_resolve_unit_label(unit_map, prod.unit), + ) + ) + + records: list[PublicTransportHistoryRecordRead] = [] + for plan, tr, site_stops in plan_rows: + voided, archived = _resolve_driver_link_flags(plan, tr) + site_tok = (tr.site_access_token or "").strip() + records.append( + PublicTransportHistoryRecordRead( + plan_id=plan.id, + plan_no=plan.plan_no or "", + transport_id=tr.id, + site_access_token=site_tok or None, + site_url=build_transport_site_url(site_tok) if site_tok else None, + license_plate=tr.license_plate, + pickup_time=plan.pickup_time, + delivery_time=plan.delivery_time, + pickup_address=plan.pickup_address, + destination_address=plan.destination_address or plan.destination_name, + products=products_by_plan.get(plan.id) or [], + stops=site_stops, + plan_voided=voided, + plan_archived=archived, + status_label=_history_status_label(voided, archived), + ) + ) + + page = PublicTransportHistoryPageRead( + license_plate=_normalize_main_plate(license_plate_hint) or None, + date=day.isoformat(), + keyword=kw or None, + records=records, + ) + return page, None + + +def save_site_feedback_for_transport( + db: Session, + transport: PlTransport, + *, + stop_type: str, + stop_id: str, + quantity: Optional[Decimal] = None, + attachment_urls: Optional[list[str]] = None, + remark: Optional[str] = None, +) -> tuple[bool, Optional[str]]: + """保存段回传。成功返回 (True, None);失败返回 (False, msg)。不 commit。""" + page = build_site_page_for_transport(db, transport) + if page.plan_voided: + return False, "运输计划已作废,无法提交回执" + if page.plan_archived: + return False, "运输计划已归档,无法提交回执" + + link = ( + db.query(PlPlanTransport) + .filter( + PlPlanTransport.transport_id == transport.id, + PlPlanTransport.del_flag == 0, + ) + .order_by(PlPlanTransport.create_time.desc()) + .first() + ) + if not link or not link.plan_id: + return False, "未找到关联计划,无法保存段回传" + + stop_type_n = (stop_type or "").strip() + stop_id_n = (stop_id or "").strip() + if stop_type_n not in ("load", "delivery") or not stop_id_n: + return False, "停靠段参数无效" + + segments = load_site_stop_segments( + db, + tenant_no=transport.tenant_no, + plan_id=link.plan_id, + transport_id=transport.id, + ) + segment_map = {(item.stop_type, item.stop_id): item for item in segments} + segment = segment_map.get((stop_type_n, stop_id_n)) + if not segment: + return False, "该段不在现场回传范围内" + + try: + save_site_stop_feedback( + db, + tenant_no=transport.tenant_no, + transport_id=transport.id, + plan_id=link.plan_id, + stop_type=stop_type_n, + stop_id=stop_id_n, + business_type=segment.business_type or "入库", + quantity=quantity, + attachment_urls=attachment_urls, + remark=remark, + ) + except ValueError as exc: + return False, str(exc) + + return True, None + + +def save_site_feedback_for_plan( + db: Session, + *, + tenant_no: str, + plan_id: str, + stop_type: str, + stop_id: str, + quantity: Optional[Decimal] = None, + attachment_urls: Optional[list[str]] = None, + remark: Optional[str] = None, +) -> tuple[bool, Optional[str]]: + plan, transport = resolve_transport_for_plan(db, tenant_no=tenant_no, plan_id=plan_id) + if not plan: + return False, "计划不存在" + if not transport: + return False, "未找到关联运输或现场链接无效" + return save_site_feedback_for_transport( + db, + transport, + stop_type=stop_type, + stop_id=stop_id, + quantity=quantity, + attachment_urls=attachment_urls, + remark=remark, + ) + + +def upload_site_attachment_for_transport( + db: Session, + transport: PlTransport, + *, + file: UploadFile, + folder: Optional[str] = "", +) -> tuple[Optional[dict[str, Any]], Optional[tuple[int, str]]]: + """上传附件。成功返回 (data, None) 并 commit;失败返回 (None, (code, msg))。""" + page = build_site_page_for_transport(db, transport) + if page.plan_voided: + return None, (400, "运输计划已作废,无法上传附件") + if page.plan_archived: + return None, (400, "运输计划已归档,无法上传附件") + if not file or not file.filename: + return None, (400, "请选择文件") + + try: + effective_folder = resolve_upload_folder( + folder=folder or "", biz_type="pl_transport_site_stop_feedback" + ) + file_content = file.file.read() + full_url = storage.upload_file(file_content, file.filename, effective_folder) + base_url = settings.MINIO_BASE_URL.rstrip("/") if settings.MINIO_BASE_URL else "" + if base_url and full_url.startswith(base_url + "/"): + file_url = full_url[len(base_url) + 1 :] + else: + file_url = full_url + + record = oss_svc.create_oss( + db, + tenant_no=transport.tenant_no, + url=file_url, + original_name=file.filename, + file_suffix=os.path.splitext(file.filename)[1].lstrip(".") if file.filename else "", + file_size=len(file_content), + file_type=file.content_type or "", + biz_type="pl_transport_site_stop_feedback", + user_id=None, + ext1=effective_folder or None, + ) + db.commit() + return ( + { + "id": record.id, + "fileName": record.original_name, + "fileUrl": record.url, + "fileSize": record.file_size, + "fileSizeDisplay": _format_file_size(record.file_size or 0), + "fileType": record.file_type, + }, + None, + ) + except Exception as exc: + db.rollback() + return None, (500, f"上传失败: {str(exc)}") + + +def upload_site_attachment_for_plan( + db: Session, + *, + tenant_no: str, + plan_id: str, + file: UploadFile, + folder: Optional[str] = "", +) -> tuple[Optional[dict[str, Any]], Optional[tuple[int, str]]]: + plan, transport = resolve_transport_for_plan(db, tenant_no=tenant_no, plan_id=plan_id) + if not plan: + return None, (400, "计划不存在") + if not transport: + return None, (400, "未找到关联运输或现场链接无效") + return upload_site_attachment_for_transport( + db, transport, file=file, folder=folder + ) diff --git a/backend/app/services/pl_transport_site_service.py b/backend/app/services/pl_transport_site_service.py index 0113b8afd..816c347d7 100644 --- a/backend/app/services/pl_transport_site_service.py +++ b/backend/app/services/pl_transport_site_service.py @@ -10,7 +10,12 @@ from typing import Optional from sqlalchemy.orm import Session from app.core.utils import generate_uuid -from app.models.pl_models import PlPlan, PlPlanProduct, PlTransport, PlTransportSiteLink +from app.models.pl_models import ( + PlPlan, + PlPlanProduct, + PlTransport, + PlTransportSiteLink, +) from app.services.db.mappers import parse_date_str from app.services.pl_dibang_bridge_service import ( _plan_type_to_business_type, diff --git a/backend/app/services/pl_vehicle_fuel_service.py b/backend/app/services/pl_vehicle_fuel_service.py new file mode 100644 index 000000000..05b5acaf6 --- /dev/null +++ b/backend/app/services/pl_vehicle_fuel_service.py @@ -0,0 +1,292 @@ +"""车辆加油登记业务逻辑""" +from __future__ import annotations + +from datetime import date, datetime +from typing import Optional + +from fastapi import HTTPException +from sqlalchemy import or_ +from sqlalchemy.orm import Session + +from app.core.db_auth import DbUserContext +from app.core.utils import generate_uuid +from app.models.models import SysUser +from app.models.pl_models import PlVehicle, PlVehicleFuelRegister +from app.schemas.pl.vehicle_fuel import ( + VehicleFuelRegisterCreate, + VehicleFuelRegisterRead, + VehicleFuelRegisterUpdate, +) +from app.services.pl_data_scope import apply_pl_scope, assert_pl_row_in_scope + + +def _generate_register_no(db: Session) -> str: + prefix = f"SXJY{date.today():%Y%m%d}" + last = ( + db.query(PlVehicleFuelRegister) + .filter(PlVehicleFuelRegister.register_no.like(f"{prefix}%")) + .order_by(PlVehicleFuelRegister.register_no.desc()) + .first() + ) + seq = int(last.register_no[-6:]) + 1 if last else 1 + return f"{prefix}{seq:06d}" + + +def _resolve_user_name(db: Session, user_id: Optional[str]) -> Optional[str]: + if not user_id: + return None + row = ( + db.query(SysUser) + .filter(SysUser.id == user_id, SysUser.del_flag == 0) + .first() + ) + return row.nick_name or row.user_no if row else None + + +def _assert_vehicle_exists(db: Session, tenant_no: str, license_plate: str) -> None: + exists = ( + db.query(PlVehicle.id) + .filter( + PlVehicle.tenant_no == tenant_no, + PlVehicle.license_plate == license_plate, + PlVehicle.del_flag == 0, + ) + .first() + ) + if not exists: + raise HTTPException(status_code=400, detail="车牌号不存在") + + +def _resolve_registrant_id( + db: Session, + tenant_no: str, + registrant_id: Optional[str], + user: DbUserContext, + *, + force_self: bool = False, +) -> str: + target = user.id if force_self else ((registrant_id or "").strip() or user.id) + row = ( + db.query(SysUser.id) + .filter( + SysUser.id == target, + SysUser.tenant_no == tenant_no, + SysUser.del_flag == 0, + SysUser.status == "1", + ) + .first() + ) + if not row: + raise HTTPException(status_code=400, detail="登记人不存在或已停用") + return target + + +def _get_row( + db: Session, tenant_no: str, register_id: str, user: DbUserContext +) -> PlVehicleFuelRegister: + row = ( + db.query(PlVehicleFuelRegister) + .filter( + PlVehicleFuelRegister.id == register_id, + PlVehicleFuelRegister.tenant_no == tenant_no, + PlVehicleFuelRegister.del_flag == 0, + ) + .first() + ) + if not row: + raise HTTPException(status_code=404, detail="加油登记不存在") + assert_pl_row_in_scope(db, row, PlVehicleFuelRegister, user, tenant_no) + return row + + +def to_read(row: PlVehicleFuelRegister, db: Session) -> VehicleFuelRegisterRead: + return VehicleFuelRegisterRead( + id=row.id, + registerNo=row.register_no, + fuelCategory=row.fuel_category, + registerDate=row.register_date, + registrantId=row.registrant_id, + registrantName=_resolve_user_name(db, row.registrant_id), + licensePlate=row.license_plate, + odometerKm=row.odometer_km, + fuelLiters=row.fuel_liters, + attachmentUrls=row.attachment_urls, + status=row.status, + remark=row.remark, + createTime=row.create_time, + updateTime=row.update_time, + ) + + +def list_registers( + db: Session, + tenant_no: str, + user: DbUserContext, + *, + q: Optional[str] = None, + register_no: Optional[str] = None, + license_plate: Optional[str] = None, + fuel_category: Optional[str] = None, + fuel_category_in: Optional[str] = None, + status: Optional[str] = None, + status_in: Optional[str] = None, + register_date_from: Optional[date] = None, + register_date_to: Optional[date] = None, + registrant_id: Optional[str] = None, + mine_only: bool = False, + ids: Optional[list[str]] = None, + page: int = 1, + page_size: int = 20, + max_page_size: Optional[int] = None, +) -> tuple[list[VehicleFuelRegisterRead], int]: + query = db.query(PlVehicleFuelRegister).filter( + PlVehicleFuelRegister.tenant_no == tenant_no, + PlVehicleFuelRegister.del_flag == 0, + ) + if mine_only: + query = query.filter(PlVehicleFuelRegister.registrant_id == user.id) + elif registrant_id: + query = query.filter(PlVehicleFuelRegister.registrant_id == registrant_id) + + if q: + query = query.filter( + or_( + PlVehicleFuelRegister.register_no.contains(q), + PlVehicleFuelRegister.license_plate.contains(q), + ) + ) + if register_no: + query = query.filter(PlVehicleFuelRegister.register_no.contains(register_no)) + if license_plate: + query = query.filter(PlVehicleFuelRegister.license_plate.contains(license_plate)) + if fuel_category: + query = query.filter(PlVehicleFuelRegister.fuel_category == fuel_category) + if fuel_category_in: + values = [v.strip() for v in str(fuel_category_in).split(",") if v.strip()] + if values: + query = query.filter(PlVehicleFuelRegister.fuel_category.in_(values)) + if status: + query = query.filter(PlVehicleFuelRegister.status == status) + if status_in: + values = [v.strip() for v in str(status_in).split(",") if v.strip()] + if values: + query = query.filter(PlVehicleFuelRegister.status.in_(values)) + if register_date_from: + query = query.filter(PlVehicleFuelRegister.register_date >= register_date_from) + if register_date_to: + query = query.filter(PlVehicleFuelRegister.register_date <= register_date_to) + if ids: + query = query.filter(PlVehicleFuelRegister.id.in_(ids)) + + query = apply_pl_scope(query, PlVehicleFuelRegister, user, db, tenant_no) + total = query.count() + page = max(1, page) + cap = int(max_page_size) if max_page_size else 500 + page_size = min(max(1, page_size), cap) + rows = ( + query.order_by(PlVehicleFuelRegister.create_time.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + return [to_read(row, db) for row in rows], total + + +def get_register( + db: Session, tenant_no: str, user: DbUserContext, register_id: str +) -> VehicleFuelRegisterRead: + return to_read(_get_row(db, tenant_no, register_id, user), db) + + +def create_register( + db: Session, + tenant_no: str, + user: DbUserContext, + payload: VehicleFuelRegisterCreate, + *, + force_self: bool = False, +) -> VehicleFuelRegisterRead: + license_plate = payload.license_plate.strip() + _assert_vehicle_exists(db, tenant_no, license_plate) + registrant_id = _resolve_registrant_id( + db, tenant_no, payload.registrant_id, user, force_self=force_self + ) + now = datetime.now() + row = PlVehicleFuelRegister( + id=generate_uuid(), + register_no=_generate_register_no(db), + fuel_category=payload.fuel_category, + register_date=payload.register_date, + registrant_id=registrant_id, + license_plate=license_plate, + odometer_km=payload.odometer_km, + fuel_liters=payload.fuel_liters, + attachment_urls=payload.attachment_urls, + status="normal", + tenant_no=tenant_no, + remark=payload.remark, + create_by=user.id, + create_dept_id=user.dept_id or "", + create_time=now, + update_by=user.id, + update_time=now, + del_flag=0, + ) + db.add(row) + db.commit() + db.refresh(row) + return to_read(row, db) + + +def update_register( + db: Session, + tenant_no: str, + user: DbUserContext, + register_id: str, + payload: VehicleFuelRegisterUpdate, + *, + force_self: bool = False, +) -> VehicleFuelRegisterRead: + row = _get_row(db, tenant_no, register_id, user) + if row.status != "normal": + raise HTTPException(status_code=400, detail="仅正常状态的登记可编辑") + if force_self and row.registrant_id != user.id: + raise HTTPException(status_code=403, detail="只能编辑本人登记") + license_plate = payload.license_plate.strip() + _assert_vehicle_exists(db, tenant_no, license_plate) + row.fuel_category = payload.fuel_category + row.register_date = payload.register_date + row.registrant_id = _resolve_registrant_id( + db, tenant_no, payload.registrant_id, user, force_self=force_self + ) + row.license_plate = license_plate + row.odometer_km = payload.odometer_km + row.fuel_liters = payload.fuel_liters + row.attachment_urls = payload.attachment_urls + row.remark = payload.remark + row.update_by = user.id + row.update_time = datetime.now() + db.commit() + db.refresh(row) + return to_read(row, db) + + +def cancel_register( + db: Session, + tenant_no: str, + user: DbUserContext, + register_id: str, + *, + force_self: bool = False, +) -> VehicleFuelRegisterRead: + row = _get_row(db, tenant_no, register_id, user) + if row.status != "normal": + raise HTTPException(status_code=400, detail="仅正常状态的登记可作废") + if force_self and row.registrant_id != user.id: + raise HTTPException(status_code=403, detail="只能作废本人登记") + row.status = "cancelled" + row.update_by = user.id + row.update_time = datetime.now() + db.commit() + db.refresh(row) + return to_read(row, db) diff --git a/backend/tests/test_pl_transport_site_service.py b/backend/tests/test_pl_transport_site_service.py new file mode 100644 index 000000000..5c2cf9e6a --- /dev/null +++ b/backend/tests/test_pl_transport_site_service.py @@ -0,0 +1,55 @@ +# backend/tests/test_pl_transport_site_service.py +from datetime import date, datetime +from unittest.mock import MagicMock + +from app.services.pl_public_transport_helpers import _parse_history_date +from app.services.pl_site_plan_service import ( + build_site_page_for_plan, + plan_matches_history_day, + resolve_transport_for_plan, +) + + +def test_parse_history_date_accepts_iso(): + assert _parse_history_date("2026-09-13") == date(2026, 9, 13) + + +def test_parse_history_date_rejects_bad(): + assert _parse_history_date("13/09/2026") is None + assert _parse_history_date("") is None + + +def test_plan_day_match_helpers(): + day = date(2026, 9, 13) + + class P: + def __init__(self, plan_date=None, pickup_time=None, delivery_time=None): + self.plan_date = plan_date + self.pickup_time = pickup_time + self.delivery_time = delivery_time + + assert plan_matches_history_day(P(plan_date=day), day) is True + assert plan_matches_history_day( + P(pickup_time=datetime(2026, 9, 13, 8, 0, 0)), day + ) is True + assert plan_matches_history_day( + P(delivery_time=datetime(2026, 9, 12, 18, 0, 0)), day + ) is False + + +def test_resolve_transport_for_plan_missing_plan(): + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = None + plan, transport = resolve_transport_for_plan( + db, tenant_no="t1", plan_id="missing" + ) + assert plan is None + assert transport is None + + +def test_build_site_page_for_plan_missing_plan(): + db = MagicMock() + db.query.return_value.filter.return_value.first.return_value = None + page, err = build_site_page_for_plan(db, tenant_no="t1", plan_id="missing") + assert page is None + assert err == "计划不存在" diff --git a/backend/tests/test_vehicle_fuel_schema.py b/backend/tests/test_vehicle_fuel_schema.py new file mode 100644 index 000000000..bb1b82622 --- /dev/null +++ b/backend/tests/test_vehicle_fuel_schema.py @@ -0,0 +1,47 @@ +"""车辆加油登记 Schema 单测""" +from datetime import date +from decimal import Decimal + +import pytest +from pydantic import ValidationError + +from app.schemas.pl.vehicle_fuel import VehicleFuelRegisterCreate + + +def test_create_accepts_bailian(): + body = VehicleFuelRegisterCreate.model_validate( + { + "fuelCategory": "bailian", + "registerDate": "2026-09-12", + "licensePlate": "皖A12345", + "fuelLiters": "50.5", + "odometerKm": "12000.0", + } + ) + assert body.fuel_category == "bailian" + assert body.fuel_liters == Decimal("50.5") + assert body.register_date == date(2026, 9, 12) + + +def test_create_rejects_invalid_category(): + with pytest.raises(ValidationError): + VehicleFuelRegisterCreate.model_validate( + { + "fuelCategory": "etc", + "registerDate": "2026-09-12", + "licensePlate": "皖A12345", + "fuelLiters": "10", + } + ) + + +def test_create_rejects_non_positive_liters(): + with pytest.raises(ValidationError): + VehicleFuelRegisterCreate.model_validate( + { + "fuelCategory": "gas_station", + "registerDate": "2026-09-12", + "licensePlate": "皖A12345", + "fuelLiters": "0", + } + ) diff --git a/docs/sql/migrations/2026-09-12-mini-pl-fuel-register-menus.sql b/docs/sql/migrations/2026-09-12-mini-pl-fuel-register-menus.sql new file mode 100644 index 000000000..10dcb6514 --- /dev/null +++ b/docs/sql/migrations/2026-09-12-mini-pl-fuel-register-menus.sql @@ -0,0 +1,63 @@ +-- 小程序:运输管理 → 加油登记 +-- 权限:全部角色授权(与 Web 端「加油登记」一致) + +SET NAMES utf8mb4; + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT + 'MINI_PL_ROOT', '运输管理', '1', 'M', '/mini/pl', NULL, NULL, + 1, '1', '1', 'shanxia2.0', 4, '小程序运输管理', + 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `sys_menu` WHERE `id` = 'MINI_PL_ROOT' AND `del_flag` = 0 +); + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT + 'MINI_PL_FUEL_REG', '加油登记', 'MINI_PL_ROOT', 'C', 'fuel-register', NULL, + 'mini:pl:fuelRegister:list', + 1, '1', '1', 'shanxia2.0', 1, NULL, + 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `sys_menu` WHERE `id` = 'MINI_PL_FUEL_REG' AND `del_flag` = 0 +); + +-- 全部角色:移动端根 + 运输管理 + 加油登记 +INSERT INTO `sys_role_menu` ( + `id`, `role_id`, `menu_id`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT + MD5(CONCAT(r.id, '|', m.menu_id, '|mini-pl-fuel|v1')), + r.id, + m.menu_id, + IFNULL(NULLIF(r.tenant_no, ''), 'shanxia2.0'), + 0, + '小程序加油登记:全角色', + 'system', + '', + NOW(), + NULL, + NOW(), + 0 +FROM `sys_role` r +JOIN ( + SELECT '1' AS menu_id UNION ALL + SELECT 'MINI_PL_ROOT' UNION ALL + SELECT 'MINI_PL_FUEL_REG' +) m +WHERE r.del_flag = 0 + AND NOT EXISTS ( + SELECT 1 FROM `sys_role_menu` x + WHERE x.role_id = r.id AND x.menu_id = m.menu_id AND x.del_flag = 0 + ); diff --git a/docs/sql/migrations/2026-09-12-pl-vehicle-fuel-register-prod-perms.sql b/docs/sql/migrations/2026-09-12-pl-vehicle-fuel-register-prod-perms.sql new file mode 100644 index 000000000..53a5422a7 --- /dev/null +++ b/docs/sql/migrations/2026-09-12-pl-vehicle-fuel-register-prod-perms.sql @@ -0,0 +1,199 @@ +-- 生产/补跑:车辆加油登记表 + 菜单 +-- 权限:全部角色 → 加油登记;仅「管理员」→ 加油信息汇总 +-- 目标库示例:.env.production → shanxia2.0 + +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS `pl_vehicle_fuel_register` ( + `id` varchar(64) NOT NULL COMMENT '主键ID', + `register_no` varchar(32) NOT NULL COMMENT '登记单号 SXJY+YYYYMMDD+6位', + `fuel_category` varchar(20) NOT NULL COMMENT '加油类别 bailian-百链 gas_station-加油站', + `register_date` date NOT NULL COMMENT '日期', + `registrant_id` varchar(64) NOT NULL COMMENT '登记人用户ID', + `license_plate` varchar(20) NOT NULL COMMENT '车牌号', + `odometer_km` decimal(10,1) DEFAULT NULL COMMENT '期初公里数', + `fuel_liters` decimal(10,2) NOT NULL COMMENT '加油升数', + `attachment_urls` varchar(2000) DEFAULT NULL COMMENT '附件URL,逗号分隔', + `status` varchar(20) NOT NULL DEFAULT 'normal' COMMENT 'normal-正常 cancelled-已作废', + `tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号', + `order_num` int(11) DEFAULT NULL COMMENT '排序号', + `remark` varchar(512) DEFAULT NULL COMMENT '备注', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人id', + `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '更新人id', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `del_flag` smallint(6) NOT NULL DEFAULT 0 COMMENT '删除标记:0-正常 1-已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_register_no` (`register_no`), + KEY `idx_tenant_date` (`tenant_no`, `register_date`), + KEY `idx_tenant_registrant` (`tenant_no`, `registrant_id`), + KEY `idx_tenant_plate` (`tenant_no`, `license_plate`), + KEY `idx_tenant_status` (`tenant_no`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆加油登记'; + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT + 'PL_VEH_FUEL_REG', '加油登记', 'PL_GROUP_VEHICLE', 'C', + 'vehicle-fuel-register', 'pl/vehicle-fuel-register/index', + 'pl:vehicle-fuel-register:list', 1, '1', '1', 'shanxia2.0', 7, NULL, + 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'PL_VEH_FUEL_REG' AND `del_flag` = 0); + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT 'PL_VFR_Q', '查询', 'PL_VEH_FUEL_REG', 'F', '', '', 'pl:vehicle-fuel-register:query', + 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'PL_VFR_Q' AND `del_flag` = 0); + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT 'PL_VFR_A', '新增', 'PL_VEH_FUEL_REG', 'F', '', '', 'pl:vehicle-fuel-register:add', + 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'PL_VFR_A' AND `del_flag` = 0); + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT 'PL_VFR_E', '编辑', 'PL_VEH_FUEL_REG', 'F', '', '', 'pl:vehicle-fuel-register:edit', + 1, '1', '1', 'shanxia2.0', 3, NULL, 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'PL_VFR_E' AND `del_flag` = 0); + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT 'PL_VFR_C', '作废', 'PL_VEH_FUEL_REG', 'F', '', '', 'pl:vehicle-fuel-register:cancel', + 1, '1', '1', 'shanxia2.0', 4, NULL, 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'PL_VFR_C' AND `del_flag` = 0); + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT + 'PL_VEH_FUEL_SUM', '加油信息汇总', 'PL_GROUP_VEHICLE', 'C', + 'vehicle-fuel-summary', 'pl/vehicle-fuel-summary/index', + 'pl:vehicle-fuel-summary:list', 1, '1', '1', 'shanxia2.0', 8, NULL, + 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'PL_VEH_FUEL_SUM' AND `del_flag` = 0); + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT 'PL_VFS_Q', '查询', 'PL_VEH_FUEL_SUM', 'F', '', '', 'pl:vehicle-fuel-summary:query', + 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'PL_VFS_Q' AND `del_flag` = 0); + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT 'PL_VFS_A', '新增', 'PL_VEH_FUEL_SUM', 'F', '', '', 'pl:vehicle-fuel-summary:add', + 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'PL_VFS_A' AND `del_flag` = 0); + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT 'PL_VFS_E', '编辑', 'PL_VEH_FUEL_SUM', 'F', '', '', 'pl:vehicle-fuel-summary:edit', + 1, '1', '1', 'shanxia2.0', 3, NULL, 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'PL_VFS_E' AND `del_flag` = 0); + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT 'PL_VFS_C', '作废', 'PL_VEH_FUEL_SUM', 'F', '', '', 'pl:vehicle-fuel-summary:cancel', + 1, '1', '1', 'shanxia2.0', 4, NULL, 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'PL_VFS_C' AND `del_flag` = 0); + +-- 全部角色:加油登记(含祖先菜单,保证侧栏可见) +INSERT INTO `sys_role_menu` ( + `id`, `role_id`, `menu_id`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT + MD5(CONCAT(r.id, '|', m.menu_id, '|fuel-reg-all|v1')), + r.id, + m.menu_id, + IFNULL(NULLIF(r.tenant_no, ''), 'shanxia2.0'), + 0, + '加油登记:全角色授权', + 'system', + '', + NOW(), + NULL, + NOW(), + 0 +FROM `sys_role` r +JOIN ( + SELECT '2' AS menu_id UNION ALL + SELECT 'PL_MENU_ROOT' UNION ALL + SELECT 'PL_GROUP_VEHICLE' UNION ALL + SELECT 'PL_VEH_FUEL_REG' UNION ALL + SELECT 'PL_VFR_Q' UNION ALL + SELECT 'PL_VFR_A' UNION ALL + SELECT 'PL_VFR_E' UNION ALL + SELECT 'PL_VFR_C' +) m +WHERE r.del_flag = 0 + AND NOT EXISTS ( + SELECT 1 FROM `sys_role_menu` x + WHERE x.role_id = r.id AND x.menu_id = m.menu_id AND x.del_flag = 0 + ); + +-- 仅「管理员」:加油信息汇总 +INSERT INTO `sys_role_menu` ( + `id`, `role_id`, `menu_id`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT + MD5(CONCAT(r.id, '|', m.menu_id, '|fuel-sum-admin|v1')), + r.id, + m.menu_id, + IFNULL(NULLIF(r.tenant_no, ''), 'shanxia2.0'), + 0, + '加油信息汇总:仅管理员', + 'system', + '', + NOW(), + NULL, + NOW(), + 0 +FROM `sys_role` r +JOIN ( + SELECT 'PL_VEH_FUEL_SUM' AS menu_id UNION ALL + SELECT 'PL_VFS_Q' UNION ALL + SELECT 'PL_VFS_A' UNION ALL + SELECT 'PL_VFS_E' UNION ALL + SELECT 'PL_VFS_C' +) m +WHERE r.del_flag = 0 + AND r.role_name = '管理员' + AND NOT EXISTS ( + SELECT 1 FROM `sys_role_menu` x + WHERE x.role_id = r.id AND x.menu_id = m.menu_id AND x.del_flag = 0 + ); diff --git a/docs/sql/migrations/2026-09-12-pl-vehicle-fuel-register.sql b/docs/sql/migrations/2026-09-12-pl-vehicle-fuel-register.sql new file mode 100644 index 000000000..c6a4a902c --- /dev/null +++ b/docs/sql/migrations/2026-09-12-pl-vehicle-fuel-register.sql @@ -0,0 +1,164 @@ +-- 车辆加油登记:新表 + 菜单(加油登记 / 加油信息汇总) +-- 独立于 ETC 充值模块;历史 pl_vehicle_recharge_* fuel 行不迁移 + +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS `pl_vehicle_fuel_register` ( + `id` varchar(64) NOT NULL COMMENT '主键ID', + `register_no` varchar(32) NOT NULL COMMENT '登记单号 SXJY+YYYYMMDD+6位', + `fuel_category` varchar(20) NOT NULL COMMENT '加油类别 bailian-百链 gas_station-加油站', + `register_date` date NOT NULL COMMENT '日期', + `registrant_id` varchar(64) NOT NULL COMMENT '登记人用户ID', + `license_plate` varchar(20) NOT NULL COMMENT '车牌号', + `odometer_km` decimal(10,1) DEFAULT NULL COMMENT '期初公里数', + `fuel_liters` decimal(10,2) NOT NULL COMMENT '加油升数', + `attachment_urls` varchar(2000) DEFAULT NULL COMMENT '附件URL,逗号分隔', + `status` varchar(20) NOT NULL DEFAULT 'normal' COMMENT 'normal-正常 cancelled-已作废', + `tenant_no` varchar(64) NOT NULL DEFAULT 'shanxia2.0' COMMENT '租户编号', + `order_num` int(11) DEFAULT NULL COMMENT '排序号', + `remark` varchar(512) DEFAULT NULL COMMENT '备注', + `create_by` varchar(64) DEFAULT NULL COMMENT '创建人id', + `create_dept_id` varchar(64) NOT NULL DEFAULT '' COMMENT '创建人部门ID', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_by` varchar(64) DEFAULT NULL COMMENT '更新人id', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `del_flag` smallint(6) NOT NULL DEFAULT 0 COMMENT '删除标记:0-正常 1-已删除', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_register_no` (`register_no`), + KEY `idx_tenant_date` (`tenant_no`, `register_date`), + KEY `idx_tenant_registrant` (`tenant_no`, `registrant_id`), + KEY `idx_tenant_plate` (`tenant_no`, `license_plate`), + KEY `idx_tenant_status` (`tenant_no`, `status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆加油登记'; + +-- 加油登记(本人) +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT + 'PL_VEH_FUEL_REG', '加油登记', 'PL_GROUP_VEHICLE', 'C', + 'vehicle-fuel-register', 'pl/vehicle-fuel-register/index', + 'pl:vehicle-fuel-register:list', 1, '1', '1', 'shanxia2.0', 7, NULL, + 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'PL_VEH_FUEL_REG' AND `del_flag` = 0); + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT 'PL_VFR_Q', '查询', 'PL_VEH_FUEL_REG', 'F', '', '', 'pl:vehicle-fuel-register:query', + 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'PL_VFR_Q' AND `del_flag` = 0); + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT 'PL_VFR_A', '新增', 'PL_VEH_FUEL_REG', 'F', '', '', 'pl:vehicle-fuel-register:add', + 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'PL_VFR_A' AND `del_flag` = 0); + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT 'PL_VFR_E', '编辑', 'PL_VEH_FUEL_REG', 'F', '', '', 'pl:vehicle-fuel-register:edit', + 1, '1', '1', 'shanxia2.0', 3, NULL, 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'PL_VFR_E' AND `del_flag` = 0); + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT 'PL_VFR_C', '作废', 'PL_VEH_FUEL_REG', 'F', '', '', 'pl:vehicle-fuel-register:cancel', + 1, '1', '1', 'shanxia2.0', 4, NULL, 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'PL_VFR_C' AND `del_flag` = 0); + +-- 加油信息汇总(全部) +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT + 'PL_VEH_FUEL_SUM', '加油信息汇总', 'PL_GROUP_VEHICLE', 'C', + 'vehicle-fuel-summary', 'pl/vehicle-fuel-summary/index', + 'pl:vehicle-fuel-summary:list', 1, '1', '1', 'shanxia2.0', 8, NULL, + 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'PL_VEH_FUEL_SUM' AND `del_flag` = 0); + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT 'PL_VFS_Q', '查询', 'PL_VEH_FUEL_SUM', 'F', '', '', 'pl:vehicle-fuel-summary:query', + 1, '1', '1', 'shanxia2.0', 1, NULL, 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'PL_VFS_Q' AND `del_flag` = 0); + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT 'PL_VFS_A', '新增', 'PL_VEH_FUEL_SUM', 'F', '', '', 'pl:vehicle-fuel-summary:add', + 1, '1', '1', 'shanxia2.0', 2, NULL, 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'PL_VFS_A' AND `del_flag` = 0); + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT 'PL_VFS_E', '编辑', 'PL_VEH_FUEL_SUM', 'F', '', '', 'pl:vehicle-fuel-summary:edit', + 1, '1', '1', 'shanxia2.0', 3, NULL, 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'PL_VFS_E' AND `del_flag` = 0); + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT 'PL_VFS_C', '作废', 'PL_VEH_FUEL_SUM', 'F', '', '', 'pl:vehicle-fuel-summary:cancel', + 1, '1', '1', 'shanxia2.0', 4, NULL, 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `id` = 'PL_VFS_C' AND `del_flag` = 0); + +-- 已拥有「车辆管理」分组的角色:同步授权新菜单及按钮 +INSERT INTO `sys_role_menu` ( + `id`, `role_id`, `menu_id`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT + MD5(CONCAT(rm.role_id, '|', m.menu_id, '|fuel|', rm.id)), + rm.role_id, + m.menu_id, + rm.tenant_no, + 0, + NULL, + 'system', + '', + NOW(), + NULL, + NOW(), + 0 +FROM `sys_role_menu` rm +JOIN ( + SELECT 'PL_VEH_FUEL_REG' AS menu_id UNION ALL + SELECT 'PL_VFR_Q' UNION ALL SELECT 'PL_VFR_A' UNION ALL SELECT 'PL_VFR_E' UNION ALL SELECT 'PL_VFR_C' UNION ALL + SELECT 'PL_VEH_FUEL_SUM' UNION ALL + SELECT 'PL_VFS_Q' UNION ALL SELECT 'PL_VFS_A' UNION ALL SELECT 'PL_VFS_E' UNION ALL SELECT 'PL_VFS_C' +) m +WHERE rm.menu_id = 'PL_GROUP_VEHICLE' + AND rm.del_flag = 0 + AND NOT EXISTS ( + SELECT 1 FROM `sys_role_menu` x + WHERE x.role_id = rm.role_id AND x.menu_id = m.menu_id AND x.del_flag = 0 + ); diff --git a/docs/sql/migrations/2026-09-13-mini-pl-transport-plan-menus.sql b/docs/sql/migrations/2026-09-13-mini-pl-transport-plan-menus.sql new file mode 100644 index 000000000..b9cabdb98 --- /dev/null +++ b/docs/sql/migrations/2026-09-13-mini-pl-transport-plan-menus.sql @@ -0,0 +1,56 @@ +-- 小程序:运输管理 → 运输计划单(仅角色「现场人员」) +SET NAMES utf8mb4; + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT + 'MINI_PL_ROOT', '运输管理', '1', 'M', '/mini/pl', NULL, NULL, + 1, '1', '1', 'shanxia2.0', 4, '小程序运输管理', + 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `sys_menu` WHERE `id` = 'MINI_PL_ROOT' AND `del_flag` = 0 +); + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT + 'MINI_PL_TRANSPORT_PLAN', '运输计划单', 'MINI_PL_ROOT', 'C', 'transport-plan', NULL, + 'mini:pl:transportPlan:list', + 1, '1', '1', 'shanxia2.0', 2, '小程序现场记录/回传', + 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `sys_menu` WHERE `id` = 'MINI_PL_TRANSPORT_PLAN' AND `del_flag` = 0 +); + +INSERT INTO `sys_role_menu` ( + `id`, `role_id`, `menu_id`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT + MD5(CONCAT(r.id, '|', m.menu_id, '|mini-pl-transport-plan|v1')), + r.id, + m.menu_id, + IFNULL(NULLIF(r.tenant_no, ''), 'shanxia2.0'), + 0, + '小程序运输计划单:现场人员', + 'system', '', NOW(), NULL, NOW(), 0 +FROM `sys_role` r +JOIN ( + SELECT '1' AS menu_id UNION ALL + SELECT 'MINI_PL_ROOT' UNION ALL + SELECT 'MINI_PL_TRANSPORT_PLAN' +) m +WHERE r.del_flag = 0 + AND r.role_name = '现场人员' + AND NOT EXISTS ( + SELECT 1 FROM `sys_role_menu` x + WHERE x.role_id = r.id AND x.menu_id = m.menu_id AND x.del_flag = 0 + ); diff --git a/docs/superpowers/plans/2026-09-13-mini-pl-transport-plan-site.md b/docs/superpowers/plans/2026-09-13-mini-pl-transport-plan-site.md new file mode 100644 index 000000000..2d6b4e7f3 --- /dev/null +++ b/docs/superpowers/plans/2026-09-13-mini-pl-transport-plan-site.md @@ -0,0 +1,577 @@ +# 小程序 · 运输计划单(现场记录/回传)Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在小程序「运输管理」下新增「运输计划单」,登录态完成与 H5 现场链接相同的现场记录列表与现场回传;顶栏对齐备用金申请;仅「现场人员」有菜单权限。 + +**Architecture:** 从 `public_transport_site` 抽出按 `tenant_no`/`plan_id` 构建列表与详情、保存回传的 service;新增 JWT 路由 `/api/transport/site-plan/*`;小程序原生 list/detail 页复用业务字段与交互,顶栏用 `bx-page` 白底样式。公开 token API 与微信链接保留不变。 + +**Tech Stack:** FastAPI + SQLAlchemy、uni-app Vue3、`dbRequest` Bearer、`uni-calendar`、`uni.uploadFile`、`sys_menu`/`sys_role_menu` 迁移 SQL。 + +**Spec:** `docs/superpowers/specs/2026-09-13-mini-pl-transport-plan-site-design.md` + +--- + +## 文件结构 + +| 路径 | 职责 | +|------|------| +| `backend/app/services/pl_transport_site_service.py` | 登录态/公开态共用:history、按 plan 解析 transport、详情、保存/上传 | +| `backend/app/api/pl/site_plan.py` | JWT 路由 `/site-plan` | +| `backend/app/api/pl/public_transport_site.py` | 改为调用 service(行为不变) | +| `backend/app/api/pl/__init__.py` | `include_router(site_plan.router)` | +| `backend/app/core/config.py` | 附件上传路径加入加解密排除 | +| `backend/tests/test_pl_transport_site_service.py` | service 单测 | +| `docs/sql/migrations/2026-09-13-mini-pl-transport-plan-menus.sql` | 菜单 + 仅绑「现场人员」 | +| `miniprogram/api/pl.js` | 增加 `sitePlanApi` | +| `miniprogram/config/api.js` | PAGE_PATHS + ROUTE_MAP | +| `miniprogram/config/workbench-menus.js` | 运输计划单菜单项 | +| `miniprogram/pages.json` | 注册 list/detail | +| `miniprogram/pages/pl/transport-plan/list.vue` | 现场记录列表 | +| `miniprogram/pages/pl/transport-plan/detail.vue` | 现场回传详情 | +| `miniprogram/styles/pl-site-plan.scss` | 列表/详情正文样式(三墙绿) | + +--- + +### Task 1: Service 抽取 — `resolve_transport_for_plan` + 单测 + +**Files:** +- Create: `backend/app/services/pl_transport_site_service.py` +- Create: `backend/tests/test_pl_transport_site_service.py` + +- [ ] **Step 1: 写失败单测(日期解析 + 计划日命中)** + +```python +# backend/tests/test_pl_transport_site_service.py +from datetime import date, datetime + +from app.api.pl.public_transport import _parse_history_date + + +def test_parse_history_date_accepts_iso(): + assert _parse_history_date("2026-09-13") == date(2026, 9, 13) + + +def test_parse_history_date_rejects_bad(): + assert _parse_history_date("13/09/2026") is None + assert _parse_history_date("") is None + + +def test_plan_day_match_helpers(): + from app.services.pl_transport_site_service import plan_matches_history_day + + day = date(2026, 9, 13) + + class P: + def __init__(self, plan_date=None, pickup_time=None, delivery_time=None): + self.plan_date = plan_date + self.pickup_time = pickup_time + self.delivery_time = delivery_time + + assert plan_matches_history_day(P(plan_date=day), day) is True + assert plan_matches_history_day( + P(pickup_time=datetime(2026, 9, 13, 8, 0, 0)), day + ) is True + assert plan_matches_history_day( + P(delivery_time=datetime(2026, 9, 12, 18, 0, 0)), day + ) is False +``` + +- [ ] **Step 2: 跑测确认失败** + +```bash +cd backend && source venv/bin/activate +pytest tests/test_pl_transport_site_service.py -v +``` + +Expected: FAIL(`plan_matches_history_day` 不存在) + +- [ ] **Step 3: 实现 service 骨架** + +```python +# backend/app/services/pl_transport_site_service.py +"""运输计划 · 现场记录/回传共用服务(公开 token 与登录态 planId)""" +from __future__ import annotations + +from datetime import date, datetime +from typing import Optional, Tuple + +from sqlalchemy.orm import Session + +from app.models.pl_models import PlPlan, PlPlanTransport, PlTransport + + +def plan_matches_history_day(plan: PlPlan, day: date) -> bool: + if plan.plan_date == day: + return True + if plan.pickup_time is not None: + pt = plan.pickup_time + if (pt.date() if isinstance(pt, datetime) else pt) == day: + return True + if plan.delivery_time is not None: + dt = plan.delivery_time + if (dt.date() if isinstance(dt, datetime) else dt) == day: + return True + return False + + +def resolve_transport_for_plan( + db: Session, *, tenant_no: str, plan_id: str +) -> Tuple[Optional[PlPlan], Optional[PlTransport]]: + """本租户计划 + 关联运输(须有有效 site_access_token)。找不到返回 (None, None)。""" + plan = ( + db.query(PlPlan) + .filter( + PlPlan.id == plan_id, + PlPlan.tenant_no == tenant_no, + PlPlan.del_flag == 0, + ) + .first() + ) + if not plan: + return None, None + link = ( + db.query(PlPlanTransport) + .filter( + PlPlanTransport.plan_id == plan.id, + PlPlanTransport.del_flag == 0, + ) + .order_by(PlPlanTransport.create_time.desc()) + .first() + ) + if not link or not link.transport_id: + return plan, None + transport = ( + db.query(PlTransport) + .filter( + PlTransport.id == link.transport_id, + PlTransport.tenant_no == tenant_no, + PlTransport.del_flag == 0, + PlTransport.site_access_token.isnot(None), + PlTransport.site_access_token != "", + ) + .first() + ) + return plan, transport +``` + +- [ ] **Step 4: 再跑测** + +```bash +pytest tests/test_pl_transport_site_service.py -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit(仅当用户要求时)** + +```bash +git add backend/app/services/pl_transport_site_service.py backend/tests/test_pl_transport_site_service.py +git commit -m "feat(pl): add transport site shared service helpers for mini planId access" +``` + +--- + +### Task 2: 迁入 history / page / feedback 到 service,公开 API 改调 service + +**Files:** +- Modify: `backend/app/services/pl_transport_site_service.py` +- Modify: `backend/app/api/pl/public_transport_site.py` + +- [ ] **Step 1: 迁入逻辑** + +从 `public_transport_site.py` 搬到 service,公开命名: + +| 原逻辑 | service 函数 | +|--------|----------------| +| `_build_public_page` | `build_site_page_for_transport(db, transport)` | +| history 查询体 | `build_site_history_page(db, *, tenant_no, date_str, keyword) -> (page\|None, err\|None)` | +| feedback 保存 | `save_site_feedback_for_transport(...)` / `save_site_feedback_for_plan(...)` | +| attachments | `upload_site_attachment_for_transport(...)` / `upload_site_attachment_for_plan(...)` | + +`build_site_history_page` 规则必须与现网一致: + +- 同租户、`transport_status=arranged`、`site_access_token` 非空 +- 日期:`plan_date` / `date(pickup_time)` / `date(delivery_time)` 命中 +- `q`:车牌或产品名 like +- 仅保留有现场回传段的计划 +- 返回 `PublicTransportHistoryPageRead` + +```python +def build_site_page_for_plan(db, *, tenant_no: str, plan_id: str): + plan, transport = resolve_transport_for_plan(db, tenant_no=tenant_no, plan_id=plan_id) + if not plan or not transport: + return None + return build_site_page_for_transport(db, transport) +``` + +- [ ] **Step 2: 精简公开路由** + +`public_transport_site.py`:`token → transport` 后调 service;路径与响应信封不变(`success_response` + `model_dump(by_alias=True)`)。 + +- [ ] **Step 3: 确认公开路径未变** + +- `GET /api/public/pl/transport-site/{token}` +- `GET /api/public/pl/transport-site/{token}/history` +- `POST .../feedback` +- `POST .../attachments` + +- [ ] **Step 4: Commit(仅当用户要求时)** + +```bash +git commit -m "refactor(pl): share transport site history/page logic via service" +``` + +--- + +### Task 3: 登录态 API `/api/transport/site-plan` + +**Files:** +- Create: `backend/app/api/pl/site_plan.py` +- Modify: `backend/app/api/pl/__init__.py` +- Modify: `backend/app/core/config.py` + +- [ ] **Step 1: 新建 `site_plan.py`** + +```python +# backend/app/api/pl/site_plan.py +"""小程序/门户登录态 · 运输计划单(现场记录/回传)""" +from __future__ import annotations + +from typing import Optional + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile +from sqlalchemy.orm import Session + +from app.core.database import get_db +from app.core.db_auth import DbUserContext, get_db_current_user +from app.core.response import error_response, success_response +from app.core.tenant_context import get_tenant_no +from app.schemas.pl.transport import PublicTransportSiteStopFeedback +from app.services import pl_transport_site_service as site_svc + +router = APIRouter(prefix="/site-plan", tags=["运输计划-现场计划单"]) + + +@router.get("/history") +def get_site_plan_history( + date: str = Query(..., description="筛选日 YYYY-MM-DD"), + q: Optional[str] = Query(None, description="车牌号/产品名称模糊"), + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +): + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + page, err = site_svc.build_site_history_page( + db, tenant_no=tenant_no, date_str=date, keyword=q + ) + if err: + return error_response(400, err) + return success_response(page.model_dump(by_alias=True)) + + +@router.get("/{plan_id}") +def get_site_plan_detail( + plan_id: str, + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +): + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + page = site_svc.build_site_page_for_plan(db, tenant_no=tenant_no, plan_id=plan_id) + if page is None: + raise HTTPException(status_code=404, detail="计划不存在或无现场链接") + return success_response(page.model_dump(by_alias=True)) + + +@router.post("/{plan_id}/feedback") +def save_site_plan_feedback( + plan_id: str, + payload: PublicTransportSiteStopFeedback, + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +): + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + ok, msg = site_svc.save_site_feedback_for_plan( + db, tenant_no=tenant_no, plan_id=plan_id, payload=payload + ) + if not ok: + return error_response(400, msg) + db.commit() + return success_response({"success": True, "message": "保存成功"}) + + +@router.post("/{plan_id}/attachments") +def upload_site_plan_attachment( + plan_id: str, + file: UploadFile = File(...), + folder: Optional[str] = Form(""), + db: Session = Depends(get_db), + user: DbUserContext = Depends(get_db_current_user), +): + tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) + data, err = site_svc.upload_site_attachment_for_plan( + db, + tenant_no=tenant_no, + plan_id=plan_id, + file=file, + folder=folder or "", + ) + if err: + return error_response(400, err) + return success_response(data) +``` + +注意:`/history` 必须注册在 `/{plan_id}` 之前。 + +- [ ] **Step 2: 注册** + +`backend/app/api/pl/__init__.py`: + +```python +from app.api.pl import site_plan +pl_router.include_router(site_plan.router) +``` + +- [ ] **Step 3: 加解密排除** + +`API_ENCRYPT_EXCLUDE_PATHS` 追加:`/api/transport/site-plan,` + +- [ ] **Step 4: 本地冒烟** + +```bash +# GET /api/transport/site-plan/history?date=2026-09-13 (带 Bearer) +``` + +Expected: `code=200`,`data.records` 为数组。 + +- [ ] **Step 5: Commit(仅当用户要求时)** + +```bash +git commit -m "feat(pl): add authenticated site-plan APIs for miniprogram" +``` + +--- + +### Task 4: 菜单 SQL(仅「现场人员」) + +**Files:** +- Create: `docs/sql/migrations/2026-09-13-mini-pl-transport-plan-menus.sql` + +- [ ] **Step 1: 写幂等迁移** + +```sql +-- 小程序:运输管理 → 运输计划单(仅角色「现场人员」) +SET NAMES utf8mb4; + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT + 'MINI_PL_ROOT', '运输管理', '1', 'M', '/mini/pl', NULL, NULL, + 1, '1', '1', 'shanxia2.0', 4, '小程序运输管理', + 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `sys_menu` WHERE `id` = 'MINI_PL_ROOT' AND `del_flag` = 0 +); + +INSERT INTO `sys_menu` ( + `id`, `menu_name`, `parent_id`, `menu_type`, `path`, `component`, `perms`, + `is_frame`, `visible`, `status`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT + 'MINI_PL_TRANSPORT_PLAN', '运输计划单', 'MINI_PL_ROOT', 'C', 'transport-plan', NULL, + 'mini:pl:transportPlan:list', + 1, '1', '1', 'shanxia2.0', 2, '小程序现场记录/回传', + 'system', '', NOW(), NULL, NOW(), 0 +FROM DUAL +WHERE NOT EXISTS ( + SELECT 1 FROM `sys_menu` WHERE `id` = 'MINI_PL_TRANSPORT_PLAN' AND `del_flag` = 0 +); + +INSERT INTO `sys_role_menu` ( + `id`, `role_id`, `menu_id`, `tenant_no`, `order_num`, `remark`, + `create_by`, `create_dept_id`, `create_time`, `update_by`, `update_time`, `del_flag` +) +SELECT + MD5(CONCAT(r.id, '|', m.menu_id, '|mini-pl-transport-plan|v1')), + r.id, + m.menu_id, + IFNULL(NULLIF(r.tenant_no, ''), 'shanxia2.0'), + 0, + '小程序运输计划单:现场人员', + 'system', '', NOW(), NULL, NOW(), 0 +FROM `sys_role` r +JOIN ( + SELECT '1' AS menu_id UNION ALL + SELECT 'MINI_PL_ROOT' UNION ALL + SELECT 'MINI_PL_TRANSPORT_PLAN' +) m +WHERE r.del_flag = 0 + AND r.role_name = '现场人员' + AND NOT EXISTS ( + SELECT 1 FROM `sys_role_menu` x + WHERE x.role_id = r.id AND x.menu_id = m.menu_id AND x.del_flag = 0 + ); +``` + +- [ ] **Step 2: 测试库执行并核对仅「现场人员」绑定** + +```sql +SELECT rm.menu_id, r.role_name +FROM sys_role_menu rm +JOIN sys_role r ON r.id = rm.role_id +WHERE rm.menu_id = 'MINI_PL_TRANSPORT_PLAN' AND rm.del_flag = 0; +``` + +- [ ] **Step 3: 生产库执行(上线窗口)** + +- [ ] **Step 4: Commit(仅当用户要求时)** + +```bash +git commit -m "docs(sql): add mini transport-plan menu for site staff role" +``` + +--- + +### Task 5: 小程序路由 / 菜单 / API 客户端 + +**Files:** +- Modify: `miniprogram/api/pl.js` +- Modify: `miniprogram/config/api.js` +- Modify: `miniprogram/config/workbench-menus.js` +- Modify: `miniprogram/pages.json` + +- [ ] **Step 1: `sitePlanApi`** + +```javascript +function unwrapData(data) { + if (!data || typeof data !== 'object') return data + if (data.code != null && data.data !== undefined) return data.data + return data +} + +export const sitePlanApi = { + history: async (params = {}) => { + const data = unwrapData(await db.get('/transport/site-plan/history', params)) + return { + date: data?.date || params.date, + keyword: data?.keyword || '', + records: Array.isArray(data?.records) ? data.records : [] + } + }, + get: async (planId) => unwrapData(await db.get(`/transport/site-plan/${planId}`)), + saveFeedback: (planId, payload) => + db.post(`/transport/site-plan/${planId}/feedback`, payload) +} +``` + +- [ ] **Step 2: PATHS + ROUTE_MAP** + +```javascript +plTransportPlan: '/pages/pl/transport-plan/list', +plTransportPlanDetail: '/pages/pl/transport-plan/detail', +'pl-transport-plan': PAGE_PATHS.plTransportPlan, +``` + +- [ ] **Step 3: 工作台菜单** + +```javascript +{ + name: '运输计划单', + enabled: true, + code: 'pl-transport-plan', + icon: '📋', + perms: 'mini:pl:transportPlan:list' +} +``` + +`pl.desc` 改为 `'加油登记 · 运输计划单'`。 + +- [ ] **Step 4: pages.json 分包注册 list/detail,`navigationStyle: custom`** + +- [ ] **Step 5: Commit(仅当用户要求时)** + +```bash +git commit -m "feat(miniprogram): wire transport-plan menu routes and API client" +``` + +--- + +### Task 6: 列表页(现场记录) + +**Files:** +- Create: `miniprogram/pages/pl/transport-plan/list.vue` +- Create: `miniprogram/styles/pl-site-plan.scss` + +- [ ] **Step 1: 顶栏对齐备用金** — `bx-page` + `topbar` + `safeAreaStyle`,标题「运输计划单」,返回工作台。 + +- [ ] **Step 2: 筛选** — `uni-calendar` `:insert="true"` 默认今日;搜索框「车牌号/产品名称」防抖 300ms;调 `sitePlanApi.history`。 + +- [ ] **Step 3: 卡片** — 对照 `web-admin/.../transport-site/history.vue`(序号、状态、车牌、stops);点击 `navigateTo` detail `?planId=`。 + +- [ ] **Step 4: 样式** — 三墙绿 `#0a9f6d` / `#059669`,不混用 `#10b981`。 + +- [ ] **Step 5: 现场人员账号冒烟列表** + +- [ ] **Step 6: Commit(仅当用户要求时)** + +```bash +git commit -m "feat(miniprogram): add transport-plan list page (site records)" +``` + +--- + +### Task 7: 详情页(现场回传) + +**Files:** +- Create: `miniprogram/pages/pl/transport-plan/detail.vue` + +- [ ] **Step 1: 顶栏「现场回传」+ 计划摘要 + 按 stop 表单**(对齐 H5 `transport-site/index.vue`)。 + +- [ ] **Step 2: `sitePlanApi.get` / `saveFeedback`;作废/归档只读;保存逻辑与 H5 `handleSave` 一致(逐段或整页)。 + +- [ ] **Step 3: 附件** — 须先填数量;`uni.chooseImage` + `uni.uploadFile` 到 `/transport/site-plan/{planId}/attachments`,`name=file`,`formData.folder`(如 `pl/transport/site-feedback`),Bearer;收集 `data.url`。 + +- [ ] **Step 4: 底栏「现场记录」+「保存回传」;导航优先 `uni.openLocation`。 + +- [ ] **Step 5: 端到端冒烟(改数量→上传→保存→管理端/H5 同源;作废不可保存) + +- [ ] **Step 6: Commit(仅当用户要求时)** + +```bash +git commit -m "feat(miniprogram): add transport-plan detail page (site feedback)" +``` + +--- + +### Task 8: 验收与图谱 + +- [ ] **Step 1: 对照 spec §9 六条验收全部勾选** +- [ ] **Step 2: `./scripts/update-graph.sh backend`(及前端/全量按脚本能力)** +- [ ] **Step 3: 实现通过后将 design 文档状态改为「已实现」** + +--- + +## Self-Review(对照 spec) + +| Spec 项 | Task | +|---------|------| +| D1 原生 + 登录态 | 2–3, 5–7 | +| D2 同租户 history | 2 | +| D3 微信链接并存 | 2(公开 API 保留) | +| D4/D5 标题 | 6/7 | +| D6 仅现场人员 | 4 | +| D7 备用金顶栏 | 6/7 | +| D8 正文对齐 H5 | 6/7 | +| 附件基线 | 7 | +| 加解密排除 | 3 | +| 验收 | 8 | + +无 TBD;路径统一 `/transport/site-plan`;详情主键统一 `planId`。 + +--- + +**Commits:** 仅在用户明确要求时提交。 diff --git a/docs/superpowers/specs/2026-09-12-pl-vehicle-fuel-register-design.md b/docs/superpowers/specs/2026-09-12-pl-vehicle-fuel-register-design.md new file mode 100644 index 000000000..b1e7d0bda --- /dev/null +++ b/docs/superpowers/specs/2026-09-12-pl-vehicle-fuel-register-design.md @@ -0,0 +1,50 @@ +# 车辆加油登记 / 加油信息汇总 — 设计文档 + +> **日期**: 2026-09-12 +> **状态**: 已实现 +> **界面模式**: P01 标准列表 CRUD(双菜单,同一实体) +> **参考页**: `web-admin/src/views/pl/vehicle-recharge-apply/index.vue` + +## 目标 + +在「运输管理 → 车辆管理」下新增: + +1. **加油登记**:仅展示登记人为当前登录人的单据;登记人默认且锁定为当前用户。 +2. **加油信息汇总**:展示全部加油登记;登记人可从用户表筛选选择(filterable)。 + +两页均支持新增、编辑、作废、导出(不支持导入)。附件多文件,图片缩略图,支持上传/下载/删除。 + +## 已确认规则 + +| # | 规则 | 决策 | +|---|------|------| +| D1 | 数据模型 | 新建 `pl_vehicle_fuel_register`,不复用 ETC 充值表;历史 `fuel` 充值行不迁移 | +| D2 | 菜单 | 双菜单:加油登记 / 加油信息汇总 | +| D3 | 本人范围 | 列表/写操作传 `mineOnly=true`,服务端强制 `registrant_id = 当前用户` | +| D4 | 加油类别 | `bailian` 百链 / `gas_station` 加油站 | +| D5 | 车牌 | `PlVehicleSelect`(自有 + 三方) | +| D6 | 状态 | `normal` / `cancelled`;仅 normal 可编辑、作废 | +| D7 | 附件 | MinIO 逗号 URL;`AttachmentUploadField`;目录 `pl/vehicle-fuel/register` | +| D8 | 单号 | `SXJY` + `YYYYMMDD` + 6 位序号 | + +## 非目标 + +- 审批流、导入、统计看板、小程序、与历史 ETC/fuel 充值数据合并 + +## API + +| 路径 | 说明 | +|------|------| +| `GET/POST /transport/vehicle-fuel-register/` | 列表 / 新建 | +| `GET/PUT /transport/vehicle-fuel-register/{id}` | 详情 / 编辑 | +| `POST /transport/vehicle-fuel-register/{id}/cancel` | 作废 | +| `POST /transport/vehicle-fuel-register/export` | 导出 | + +## 前端 + +| 菜单 | Hash | 组件 | +|------|------|------| +| 加油登记 | `/#/pl/vehicle-mgmt/vehicle-fuel-register` | `views/pl/vehicle-fuel-register/index.vue` | +| 加油信息汇总 | `/#/pl/vehicle-mgmt/vehicle-fuel-summary` | `views/pl/vehicle-fuel-summary/index.vue` | + +共享实现:`FuelRegisterListPage.vue`(`mode=mine|summary`)。 diff --git a/docs/superpowers/specs/2026-09-13-mini-pl-transport-plan-site-design.md b/docs/superpowers/specs/2026-09-13-mini-pl-transport-plan-site-design.md index 48d9b8889..19ffd80d3 100644 --- a/docs/superpowers/specs/2026-09-13-mini-pl-transport-plan-site-design.md +++ b/docs/superpowers/specs/2026-09-13-mini-pl-transport-plan-site-design.md @@ -1,7 +1,7 @@ # 小程序 · 运输计划单(现场记录 / 现场回传)— 设计文档 > **日期**: 2026-09-13 -> **状态**: 已确认,待实现 +> **状态**: 已实现(待测试/生产执行菜单 SQL + 真机冒烟) > **方案**: 方案 1 — 小程序原生页 + 登录态 API(复用现有现场业务逻辑) > **对齐**: H5 现场链接「现场记录 / 现场回传」;顶栏对齐小程序「备用金申请」 diff --git a/graphify-out/manifest.json b/graphify-out/manifest.json index b62fb5d2c..48e53ea1c 100644 --- a/graphify-out/manifest.json +++ b/graphify-out/manifest.json @@ -776,11 +776,11 @@ "hash": "caab19ac3b94b1cc6b27a1a819f5235c" }, "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/types/import/components.d.ts": { - "mtime": 1789263378.232033, - "hash": "a4cfdb2c55ee3bf8c51e7a5489ccd6a1" + "mtime": 1789273863.4586453, + "hash": "057b5250514ccd72d722e9447879126e" }, "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/types/import/auto-imports.d.ts": { - "mtime": 1789260603.6858466, + "mtime": 1789273849.6347933, "hash": "6a338312d64e22c3b1d2281e57938c3f" }, "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/types/store/index.ts": { @@ -804,8 +804,8 @@ "hash": "0e1adad822924d047c25d38d50e3237e" }, "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/composables/usePlPlanArrangeSubmit.ts": { - "mtime": 1788077084.0, - "hash": "ca4e4871e603c18218cbfe03719e1844" + "mtime": 1789267567.7964866, + "hash": "6d8def8827ffce96c0bf6aa5b237699f" }, "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/composables/listTableViewportHeight.ts": { "mtime": 1788259500.0, @@ -2180,8 +2180,8 @@ "hash": "69d87a9d7d45b37495bc398d13672c89" }, "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-view-tabs/index.vue": { - "mtime": 1789263651.161945, - "hash": "ebbe3ad287333f1e88f78f603db0470b" + "mtime": 1789273776.8458514, + "hash": "36253169f70e37bfde8a097b3cd01122" }, "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/product-alias-brand-dialog/index.vue": { "mtime": 1786077982.0, @@ -2192,16 +2192,16 @@ "hash": "2fb55dfac057d0e40abd65aab57ee481" }, "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-vehicle-summary-strip/index.vue": { - "mtime": 1783239411.0, - "hash": "c4e348a983d9075b7bc0c128a707387f" + "mtime": 1789273772.0741577, + "hash": "6315371e6aa8118dec366dbcdd69f049" }, "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-contract-multi-select/index.vue": { "mtime": 1788058688.0, "hash": "3f81beea5029c5e507d315c3823b3565" }, "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-form-dialog/index.vue": { - "mtime": 1789034871.829888, - "hash": "cb7d3e8a318a8053c2297195f6516b14" + "mtime": 1789267642.3726788, + "hash": "bb7733d73b9bcb5df0ed35aaa5b63207" }, "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-plan-form-dialog/PlPlanFormProductTable.vue": { "mtime": 1788424357.7727585, @@ -2256,8 +2256,8 @@ "hash": "c42753b1e144c352e5a35f50be6f76aa" }, "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-personnel-form-dialog/index.vue": { - "mtime": 1787150338.0, - "hash": "fd666566c50154faedc6271348b8d5f6" + "mtime": 1789267301.17986, + "hash": "8f7e8b2d9d9dc3ba2901a198c260801c" }, "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/components/business/pl-vehicle-view-panel/index.vue": { "mtime": 1783753612.0, @@ -2496,8 +2496,8 @@ "hash": "84f5a742695a2fa0ff8553ff8ba274a9" }, "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/pl/index.ts": { - "mtime": 1789263701.650148, - "hash": "794add3a445e3c978a96f35e2f15386a" + "mtime": 1789273938.8788133, + "hash": "65463fd71289b858f3c4eb940be4ea29" }, "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/api/pl/public-transport.ts": { "mtime": 1789263711.868421, @@ -2828,8 +2828,8 @@ "hash": "5f51a1e7cf709ac16edf6137db0890ee" }, "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/pl/personnel/index.vue": { - "mtime": 1788871200.6239636, - "hash": "1391f54c9a5156d423ab88eed0f6054c" + "mtime": 1789267305.37982, + "hash": "34b5936c3ae2869a07ed117135f5a699" }, "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/pl/personnel/columnFilterDefs.ts": { "mtime": 1788685527.5720243, @@ -3416,12 +3416,12 @@ "hash": "74dc01a2075cd8c9b553a5f227c26dab" }, "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/public/pl/pound-qrcode/index.vue": { - "mtime": 1789263495.0901625, - "hash": "d11a275cebd216ec8a12326ae4fa592e" + "mtime": 1789264575.3600779, + "hash": "ceac23ca4dff751244dbc54060c64d04" }, "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/public/pl/transport/index.vue": { - "mtime": 1789263777.1986926, - "hash": "3623ed2f936def048dfb86c1d6f93609" + "mtime": 1789264575.231444, + "hash": "cab8d270f6fd228af57ff3d9670d172a" }, "/Users/zlwu/work/00code/shanxia2.0/web-admin/src/views/public/pl/transport/history.vue": { "mtime": 1787803971.0, diff --git a/miniprogram.zip b/miniprogram.zip deleted file mode 100644 index 5a4b8b302..000000000 Binary files a/miniprogram.zip and /dev/null differ diff --git a/miniprogram/pages/pl/fuel-register/form.vue b/miniprogram/pages/pl/fuel-register/form.vue new file mode 100644 index 000000000..479a75dd0 --- /dev/null +++ b/miniprogram/pages/pl/fuel-register/form.vue @@ -0,0 +1,537 @@ +