"""业务提取产品与最近有效发货参照;预览指纹防止源变化后执行旧操作。""" from collections import defaultdict from decimal import ROUND_HALF_UP, Decimal from sqlalchemy import or_, select from sqlalchemy.exc import IntegrityError from app.core.exceptions import BadRequestError, NotFoundError from app.models import Business, BusinessItem, Product from app.services import audit_service from app.services.audit_service import AuditRecord from app.services.tplus.snapshots import source_records, source_statement from app.services.tplus.workbench import WorkbenchError, digest def recent_trades(session, source, ids): if not ids: return {} try: lines = list( session.scalars( source_statement(session, source, "shipment_items").where( source_statement_inventory_filter(ids) ) ) ) parent_ids = { str(r.payload_json["source_parent_id"]) for r in lines if r.payload_json.get("source_parent_id") is not None } heads = { r.source_id: r for r in source_records(session, source, "shipments", list(parent_ids)) } except WorkbenchError: return {} latest = {} for record in lines: raw = record.payload_json head = heads.get(str(raw.get("source_parent_id"))) if head is None: continue value = head.payload_json if str(value.get("voucherState")) != "189" or value.get("isCounteractDelivery") not in ( None, 0, False, "0", ): continue day = str(value.get("voucherdate") or "")[:10] try: quantity = Decimal(str(raw.get("quantity"))) price = ( Decimal(str(raw["taxPrice"])) if raw.get("taxPrice") is not None else Decimal(str(raw["taxAmount"])) / quantity ) if not quantity.is_finite() or quantity <= 0 or not price.is_finite() or price < 0: continue except (ValueError, ArithmeticError, TypeError, KeyError): continue key = str(raw.get("idinventory")) order = day, int(head.source_id), int(record.source_id) if not day or (key in latest and order <= latest[key][0]): continue if ( quantity > Decimal("999999999999.99") or quantity.normalize().as_tuple().exponent < -2 or price > Decimal("9999999999.9999") ): # 无法以当前产品档案精度无损保存的记录保持待核对,不截断数量。 continue latest[key] = ( order, { "quantity": str(quantity), "price": str(price.quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)), "date": day, "source_no": head.source_no, "source_id": head.source_id, "line_id": record.source_id, "source_database": source, }, ) return {key: value[1] for key, value in latest.items()} def source_statement_inventory_filter(ids): from app.models import TPlusSyncRecord return TPlusSyncRecord.payload_json["idinventory"].as_integer().in_([int(v) for v in ids]) def candidates(session, keyword="", page_num=1, page_size=20): condition = BusinessItem.product_id.is_(None) if keyword: condition &= or_( BusinessItem.product_name.contains(keyword, autoescape=True), Business.business_no.contains(keyword, autoescape=True), ) rows = session.execute( select(BusinessItem, Business.business_no, Business.business_date) .join(Business, Business.id == BusinessItem.business_id) .where(condition) .order_by(BusinessItem.id.desc()) .offset((page_num - 1) * page_size) .limit(page_size + 1) ).all() return { "rows": [ { "id": row.id, "business_no": number, "business_date": str(day) if day else None, "product_name": row.product_name, "spec_model": row.spec_model, "unit": row.unit, "product_category": row.product_category, "quantity": str(row.quantity), "sale_price": str(row.sale_price) if row.sale_price is not None else None, } for row, number, day in rows[:page_size] ], "has_more": len(rows) > page_size, "page_num": page_num, } def extraction_plan(session, ids, *, lock=False): if len(ids) != len(set(ids)): raise BadRequestError("不能重复选择同一业务明细") statement = ( select(BusinessItem, Business.business_date) .join(Business, Business.id == BusinessItem.business_id) .where(BusinessItem.id.in_(ids)) .order_by(BusinessItem.id) ) if lock: statement = statement.with_for_update().execution_options(populate_existing=True) source = session.execute(statement).all() if len(source) != len(ids): raise BadRequestError("所选业务明细不存在或已变化") groups = defaultdict(list) for item, day in source: groups[(item.product_name.strip(), (item.spec_model or "").strip())].append((item, day)) rows = [] for (name, spec), members in groups.items(): target_statement = select(Product).where( Product.product_name == name, Product.spec_model == spec ) if lock: target_statement = target_statement.with_for_update().execution_options( populate_existing=True ) target = session.scalar(target_statement) units = {m.unit or "" for m, _ in members} categories = {m.product_category for m, _ in members} conflict = "所选明细单位或核算分类不一致" if len(units) > 1 or len(categories) > 1 else None if target and ( target.product_category not in categories or (target.unit or "") not in units ): conflict = "同名同规格档案的单位或核算分类不同" newest, day = max( members, key=lambda pair: (str(pair[1] or ""), pair[0].business_id, pair[0].id) ) rows.append( { "product_name": name, "spec_model": spec, "unit": newest.unit, "product_category": newest.product_category, "quantity": str(newest.quantity), "sale_price": str(newest.sale_price) if newest.sale_price is not None else None, "item_ids": [m.id for m, _ in members], "target_id": target.id if target else None, "action": "CONFLICT" if conflict else "LINK" if target else "CREATE", "reason": conflict, "already_linked": [m.product_id for m, _ in members], "target_fingerprint": digest( {c.name: getattr(target, c.name) for c in target.__table__.columns} ) if target else None, "date": str(day) if day else None, } ) return {"rows": rows, "fingerprint": digest(rows)} def extract(session, payload, actor, meta=None): with audit_service.audit( session, operator=actor, record=AuditRecord( module="产品管理", action="提取产品", summary=f"从{len(payload.item_ids)}条业务明细提取产品", request_data=payload.model_dump(), ), meta=meta, ): plan = extraction_plan(session, payload.item_ids, lock=True) if payload.fingerprint != plan["fingerprint"]: raise BadRequestError("业务明细或产品档案已变化,请重新预览") for row in plan["rows"]: if row["action"] == "CONFLICT": continue try: with session.begin_nested(): target = ( session.get(Product, row["target_id"]) if row["target_id"] else Product( product_name=row["product_name"], spec_model=row["spec_model"], unit=row["unit"], product_category=row["product_category"], stock_quantity=Decimal(row["quantity"]), sale_price=Decimal(row["sale_price"]) if row["sale_price"] is not None else None, status=1, quantity_price_manual=1, ) ) session.add(target) session.flush() row["target_id"] = target.id for item_id in row["item_ids"]: item = session.get(BusinessItem, item_id) # 仅建立档案关联,不更改历史冗余名称、金额或冻结核算资料。 if item.product_id is None: item.product_id = target.id elif item.product_id != target.id: raise BadRequestError("业务明细已关联不同产品,请重新核对") session.flush() except IntegrityError: row["action"], row["reason"] = "CONFLICT", "并发建立了同名档案,请重新预览" session.commit() return plan def restore_source(session, product_id, actor, meta=None): product = session.get(Product, product_id, with_for_update=True) if product is None: raise NotFoundError("产品不存在") if not product.source_trade_json: raise BadRequestError("没有可恢复的有效发货来源") with audit_service.audit( session, operator=actor, record=AuditRecord( module="产品管理", action="恢复源值", summary=f"产品#{product_id}恢复最近有效发货参照" ), meta=meta, ): product.quantity_price_manual = 0 product.stock_quantity = Decimal(product.source_trade_json["quantity"]) product.sale_price = Decimal(product.source_trade_json["price"]) session.commit() return {"id": product_id, "restored": True}