diff --git a/backend/app/api/pl/transport.py b/backend/app/api/pl/transport.py index 2eff5adf5..9e7f10cc4 100644 --- a/backend/app/api/pl/transport.py +++ b/backend/app/api/pl/transport.py @@ -40,6 +40,7 @@ from app.schemas.pl.transport import ( TransportSiteLinkRead, TransportListQuery, TransportListResponse, + TransportDriverProductFeedbackRead, TransportRead, ) from app.services.pl_dibang_bridge_service import ( @@ -50,6 +51,7 @@ from app.services.pl_inventory_link_read import build_transport_inventory_link_r from app.services.pl_io_order_service import ( apply_dibang_prefill_to_order, create_io_order_for_transport, + void_io_orders_for_plan, ) from app.services.pl_plan_service import ( is_ton_product_unit, @@ -60,10 +62,30 @@ from app.services.pl_transport_resource_snapshot_service import ( build_transport_resource_snapshot, ) from app.services.pl_carrier_service import assert_carrier_eligible_for_transport +from app.services.pl_feedback_brand_qty import load_transport_driver_product_feedbacks router = APIRouter(prefix="/order", tags=["运输计划-运输管理"]) +def _transport_to_read(db: Session, transport: PlTransport) -> TransportRead: + base = TransportRead.model_validate(transport) + feedbacks = load_transport_driver_product_feedbacks( + db, + tenant_no=transport.tenant_no, + transport_id=transport.id, + ) + if not feedbacks: + return base + return base.model_copy( + update={ + "driver_product_feedbacks": [ + TransportDriverProductFeedbackRead.model_validate(item) + for item in feedbacks + ] + } + ) + + def _generate_transport_no(db: Session) -> str: """生成运输编号 SXYS######""" now = date.today() @@ -227,7 +249,7 @@ def _is_rearrange(plan: PlPlan, existing_transport: Optional[PlTransport]) -> bo """仅计划已正式安排才算「调整运输」。 创建/编辑表单预选车辆会留下 draft 运输单,计划仍为 pending; - 若把非 cancelled 运输单都当成 rearrange,入库/出库会被误拦截。 + 若把非 cancelled 运输单都当成 rearrange,会把首次正式安排误判为调整。 existing_transport 保留入参以兼容调用方;草稿复用由后续分支处理。 """ _ = existing_transport @@ -516,13 +538,6 @@ def arrange_transport( is_rearrange = _is_rearrange(plan, existing_transport) - # 入库/出库:已安排后完全冻结,仅允许作废后新建计划再安排 - if is_rearrange and plan.plan_type in ("inbound", "outbound"): - raise HTTPException( - status_code=400, - detail="已安排的入库/出库计划不可调整运输,请作废后新建计划再安排", - ) - if is_rearrange and existing_transport and existing_transport.vehicle_id: _assert_vehicle_carrier_eligible( db, @@ -646,6 +661,7 @@ def arrange_transport( ) apply_resource_snapshot_json(plan=plan, transport=transport, snapshot=snapshot) plan.transport_status = "arranged" + plan.follow_status = "arranged" plan.freight_amount = freight_amount or 0 plan.update_by = user.id @@ -660,6 +676,14 @@ def arrange_transport( if plan.plan_type in ("inbound", "outbound"): try: + if is_rearrange: + # 调整运输:先作废旧出入库单(现场链接随之失效),再重建 + void_io_orders_for_plan( + db, + tenant_no=tenant_no, + plan_id=plan.id, + user_id=user.id, + ) io_result = create_io_order_for_transport( db, tenant_no=tenant_no, @@ -753,7 +777,7 @@ def get_transport( ) -> TransportRead: tenant_no = get_tenant_no(db, user.to_tenant_user_dict()) transport = _get_transport(db, tenant_no, id, user) - return transport + return _transport_to_read(db, transport) # ============================================================================= diff --git a/backend/app/schemas/pl/transport.py b/backend/app/schemas/pl/transport.py index 0e80c4f39..1e3da7e01 100644 --- a/backend/app/schemas/pl/transport.py +++ b/backend/app/schemas/pl/transport.py @@ -120,6 +120,15 @@ class TransportUpdate(BaseModel): model_config = {"populate_by_name": True} +class TransportDriverProductFeedbackRead(BaseModel): + plan_product_id: str = Field(alias="planProductId") + product_name: str = Field(default="", alias="productName") + actual_qty: Optional[Decimal] = Field(default=None, alias="actualQty") + brand_summary: Optional[str] = Field(default=None, alias="brandSummary") + brand_qtys: Optional[list[BrandQtyItem]] = Field(default=None, alias="brandQtys") + model_config = {"populate_by_name": True} + + class TransportRead(TransportBase, PlanResourceSnapshotMixin): id: str transport_no: str = Field(alias="transportNo") @@ -128,6 +137,9 @@ class TransportRead(TransportBase, PlanResourceSnapshotMixin): feedback_qty: Optional[Decimal] = Field(default=None, alias="feedbackQty") feedback_images: Optional[str] = Field(default=None, alias="feedbackImages") feedback_remark: Optional[str] = Field(default=None, alias="feedbackRemark") + driver_product_feedbacks: list[TransportDriverProductFeedbackRead] = Field( + default_factory=list, alias="driverProductFeedbacks" + ) tenant_no: str = Field(alias="tenantNo") order_num: Optional[int] = Field(default=None, alias="orderNum") create_by: Optional[str] = Field(default=None, alias="createBy") diff --git a/backend/app/services/pl_feedback_brand_qty.py b/backend/app/services/pl_feedback_brand_qty.py index f8696e0d3..a44927b5d 100644 --- a/backend/app/services/pl_feedback_brand_qty.py +++ b/backend/app/services/pl_feedback_brand_qty.py @@ -215,3 +215,81 @@ def persist_transport_product_feedback( ) transport.feedback_qty = total_qty + + +def load_transport_driver_product_feedbacks( + db: Session, + *, + tenant_no: str, + transport_id: str, +) -> list[dict]: + """管理端读运输单时附司机按产品回传及品牌摘要。""" + from app.models.pl_models import ( + PlPlanProduct, + PlTransportProductBrandQty, + PlTransportProductFeedback, + ) + + feedback_rows = ( + db.query(PlTransportProductFeedback) + .filter( + PlTransportProductFeedback.transport_id == transport_id, + PlTransportProductFeedback.tenant_no == tenant_no, + PlTransportProductFeedback.del_flag == 0, + ) + .order_by(PlTransportProductFeedback.order_num.asc()) + .all() + ) + if not feedback_rows: + return [] + + plan_product_ids = [fb.plan_product_id for fb in feedback_rows if fb.plan_product_id] + products_by_id: dict[str, PlPlanProduct] = {} + if plan_product_ids: + product_rows = ( + db.query(PlPlanProduct) + .filter( + PlPlanProduct.id.in_(plan_product_ids), + PlPlanProduct.tenant_no == tenant_no, + PlPlanProduct.del_flag == 0, + ) + .all() + ) + products_by_id = {row.id: row for row in product_rows} + + brand_qtys_by_plan_product: dict[str, list[dict]] = {} + feedback_ids = [fb.id for fb in feedback_rows] + brand_qty_rows = ( + db.query(PlTransportProductBrandQty) + .filter( + PlTransportProductBrandQty.transport_id == transport_id, + PlTransportProductBrandQty.tenant_no == tenant_no, + PlTransportProductBrandQty.del_flag == 0, + PlTransportProductBrandQty.feedback_id.in_(feedback_ids), + ) + .order_by(PlTransportProductBrandQty.order_num.asc()) + .all() + ) + for row in brand_qty_rows: + brand_qtys_by_plan_product.setdefault(row.plan_product_id, []).append( + { + "brandId": row.brand_id or "", + "brandName": row.brand_name, + "qty": row.qty, + } + ) + + result: list[dict] = [] + for fb in feedback_rows: + brand_qtys = brand_qtys_by_plan_product.get(fb.plan_product_id, []) + pp = products_by_id.get(fb.plan_product_id or "") + result.append( + { + "planProductId": fb.plan_product_id, + "productName": (pp.product_name if pp else None) or "", + "actualQty": fb.actual_qty, + "brandQtys": brand_qtys or None, + "brandSummary": format_brand_qty_summary(brand_qtys), + } + ) + return result diff --git a/web-admin/src/api/pl/index.ts b/web-admin/src/api/pl/index.ts index df1373101..6845dbdae 100644 --- a/web-admin/src/api/pl/index.ts +++ b/web-admin/src/api/pl/index.ts @@ -570,6 +570,14 @@ export interface PlanArchiveBrandQtyItem { qty: number } +export interface TransportDriverProductFeedback { + planProductId: string + productName: string + actualQty?: number + brandSummary?: string + brandQtys?: PlanArchiveBrandQtyItem[] +} + export interface PlanArchiveProductItem { id: string actualQty: number @@ -702,6 +710,7 @@ export interface Transport { feedbackQty?: number feedbackImages?: string feedbackRemark?: string + driverProductFeedbacks?: TransportDriverProductFeedback[] remark?: string createTime: string } @@ -787,6 +796,10 @@ export interface PlIoOrderItem { productName?: string brandSummary?: string brandName?: string + brandMode?: string + brandId?: string + brandOptions?: Array<{ brandId: string; brandName?: string }> + brandQtys?: PlanArchiveBrandQtyItem[] specification?: string unit?: string unitLabel?: string diff --git a/web-admin/src/components/business/pl-plan-driver-feedback-dialog/index.vue b/web-admin/src/components/business/pl-plan-driver-feedback-dialog/index.vue index 0e840da1a..337d86e7f 100644 --- a/web-admin/src/components/business/pl-plan-driver-feedback-dialog/index.vue +++ b/web-admin/src/components/business/pl-plan-driver-feedback-dialog/index.vue @@ -125,7 +125,8 @@ import { planApi, type Plan, type PlanDriverFeedbackUpdateBody, - type PlanProduct + type PlanProduct, + type TransportDriverProductFeedback } from '@/api/pl' import { useDictStore } from '@/store/modules/dict' import { pickBxData } from '@/utils/http/bx' @@ -152,6 +153,7 @@ const props = defineProps<{ feedbackImages?: string | string[] | null feedbackTime?: string | null feedbackQty?: number | null + driverProductFeedbacks?: TransportDriverProductFeedback[] | null }>() const emit = defineEmits<{ @@ -212,23 +214,40 @@ function resolveUnitLabel(unit?: string) { function mapBrandQtyRows( prod: PlanProduct, brandOptions: BrandOption[], - seedActualQty: number + seedActualQty: number, + savedBrandQtys?: Array<{ brandId: string; brandName?: string; qty: number }> | null ): BrandQtyRow[] { + const savedPositive = (savedBrandQtys || []) + .filter(r => Number(r.qty) > 0) + .map(r => ({ + brandId: r.brandId, + brandName: r.brandName || '', + qty: Number(r.qty) + })) if (prod.brandMode === 'required') { const brandId = prod.brandId || brandOptions[0]?.brandId || '' const brandName = prod.brandName || brandOptions.find(o => o.brandId === brandId)?.brandName || '指定品牌' - return brandId ? [{ brandId, brandName, qty: seedActualQty }] : [] + if (!brandId) return [] + const saved = savedPositive.find(r => r.brandId === brandId) + return [{ brandId, brandName, qty: saved ? saved.qty : seedActualQty }] } if (prod.brandMode === 'any') { + const qtyMap = new Map(savedPositive.map(r => [r.brandId, r.qty])) const rows = brandOptions.map(opt => ({ brandId: opt.brandId, brandName: opt.brandName, - qty: 0 + qty: qtyMap.get(opt.brandId) ?? 0 })) - if (rows.length === 1) { + // 保存过但不在 options 里的品牌也补回 + for (const saved of savedPositive) { + if (!rows.some(r => r.brandId === saved.brandId)) { + rows.push(saved) + } + } + if (!savedPositive.length && rows.length === 1) { rows[0].qty = seedActualQty } return rows @@ -255,8 +274,10 @@ async function loadBrandOptions(productId: string): Promise { function resolveSeedActualQty( prod: PlanProduct, transportFeedbackQty: number | null | undefined, - productCount: number + productCount: number, + savedFeedback?: TransportDriverProductFeedback | null ) { + if (savedFeedback?.actualQty != null) return Number(savedFeedback.actualQty) if (prod.actualQty != null) return Number(prod.actualQty) if (productCount === 1 && transportFeedbackQty != null) { return Number(transportFeedbackQty) @@ -266,6 +287,9 @@ function resolveSeedActualQty( async function buildProductRows(plan: Plan, transportFeedbackQty?: number | null) { const products = Array.isArray(plan.products) ? plan.products : [] + const savedByPlanProductId = new Map( + (props.driverProductFeedbacks || []).map(item => [item.planProductId, item]) + ) const brandOptionMap = new Map() await Promise.all( @@ -283,8 +307,19 @@ async function buildProductRows(plan: Plan, transportFeedbackQty?: number | null : prod.brandId ? [{ brandId: prod.brandId, brandName: prod.brandName || '指定品牌' }] : [] - const seedActualQty = resolveSeedActualQty(prod, transportFeedbackQty, products.length) - const brandQtyRows = mapBrandQtyRows(prod, brandOptions, seedActualQty) + const savedFeedback = savedByPlanProductId.get(prod.id) || null + const seedActualQty = resolveSeedActualQty( + prod, + transportFeedbackQty, + products.length, + savedFeedback + ) + const brandQtyRows = mapBrandQtyRows( + prod, + brandOptions, + seedActualQty, + savedFeedback?.brandQtys + ) const actualQty = prod.brandMode ? Number(brandQtySum(brandQtyRows).toFixed(3)) : seedActualQty diff --git a/web-admin/src/components/business/pl-plan-view-tabs/index.vue b/web-admin/src/components/business/pl-plan-view-tabs/index.vue index 0c023b431..9314f8dea 100644 --- a/web-admin/src/components/business/pl-plan-view-tabs/index.vue +++ b/web-admin/src/components/business/pl-plan-view-tabs/index.vue @@ -119,58 +119,81 @@

司机回传信息

- - - {{ hasDriverFeedback ? '编辑' : '补充' }} - +
+ + + {{ hasDriverFeedback ? '编辑' : '补充' }} + + + + 复制司机链接 + +

暂无司机回传

-
- - - - - - - - - - - -
+
@@ -178,14 +201,25 @@

{{ ioOrder ? (ioOrder.kind === 'inbound' ? '入库单回传' : '出库单回传') : '现场回传信息' }}

- - - 复制现场链接 - +
+ + + {{ ioOrderHasFeedback ? '编辑' : '补充' }} + + + + 复制现场链接 + +