"""已核实的独立业务从源快照进入正式单据;预览、指纹、执行分别留痕。""" import copy from collections import Counter from datetime import date, datetime from decimal import Decimal from types import SimpleNamespace from sqlalchemy import select from app.models import ( Business, BusinessItem, PayrollInput, Personnel, Product, Receipt, Shipment, ShipmentItem, TPlusSourceLink, TPlusWorkLine, User, ) from app.services import payroll_months from app.services.authorization_service import require_permission from app.services.no_generator import next_no from app.services.tplus.calculation_preview import execute_preview from app.services.tplus.fifo import money from app.services.tplus.workbench import WorkbenchError, digest, get_run def _link(session, source, entity, source_id): return session.scalar( select(TPlusSourceLink).where( TPlusSourceLink.source_database == source, TPlusSourceLink.entity_type == entity, TPlusSourceLink.source_id == str(source_id), ) ) def _latest_input(session, business_id): return session.scalar( select(PayrollInput) .where(PayrollInput.business_id == business_id) .order_by(PayrollInput.version.desc()) .limit(1) ) def _append_compatible(old, new): """只能新增单据、补充事实;既有单据金额、日期与原发货归属变化进入差异处理。""" for entity, fields, identity in ( ( "shipments", ( "date", "amount", "quantity", "product_id", "unit", "personnel_id", "business_key", "performance_excluded", "correction_pair_key", ), lambda r: (r["source_id"], r["line_id"]), ), ( "receipts", ("date", "amount", "voucher_confirmed", "exclusion", "redirect_business_key"), lambda r: r["source_id"], ), ): current = {identity(r): r for r in new[entity]} for previous in old[entity]: candidate = current.get(identity(previous)) if candidate is None or any( str(previous.get(f)) != str(candidate.get(f)) for f in fields ): return False return all( old["group"].get(k) == new["group"].get(k) for k in ("opening_balance", "excluded_receipt_ids", "customer_id") ) def _target_state(session, link): if link is None: return None business = session.get(Business, link.target_id) if business is None: raise WorkbenchError("BUSINESS_TARGET_MISSING") tables = ( (Business, Business.id == business.id), (BusinessItem, BusinessItem.business_id == business.id), (Shipment, Shipment.business_id == business.id), (Receipt, Receipt.business_id == business.id), ) result = {} for model, condition in tables: result[model.__tablename__] = [ {c.name: str(getattr(r, c.name)) for c in model.__table__.columns} for r in session.scalars(select(model).where(condition).order_by(model.id)) ] return result def _build(session, run): preview = SimpleNamespace(input_json=run.input_json, source_database=run.source_database) execute_preview(session, preview, save_lines=False, customer_scope=True) frozen = preview.snapshot_json rows = [] targets = {} seen_customers = set() for key, values in frozen["calculation_inputs"].items(): summary = preview.summary_json["groups"][key] row = {"entity_type": "businesses", "source_id": key, "action": "PENDING", "reason": None} rows.append(row) if summary["status"] not in ("READY", "SUCCEEDED"): row["reason"] = ",".join(summary.get("reasons", [])) or "BUSINESS_DATA_PENDING" continue customer_id = values["group"]["customer_id"] if customer_id in seen_customers: row["action"], row["reason"] = "SKIP", "CUSTOMER_SCOPE_ALREADY_SELECTED" continue seen_customers.add(customer_id) active_ships = [s for s in values["shipments"] if not s.get("performance_excluded")] if not active_ships: row["action"], row["reason"] = "SKIP", "CORRECTION_EXCLUDED" continue if any(not ship.get("guidance_finalized") for ship in active_ships): row["reason"] = "GUIDANCE_FINALIZATION_REQUIRED" continue business_key = "customer:" + customer_id row["business_key"] = business_key row["input_hash"] = digest(values) link = _link(session, run.source_database, "businesses", business_key) targets[key] = _target_state(session, link) if link: current = _latest_input(session, link.target_id) row["target_id"] = link.target_id if current and current.fingerprint == row["input_hash"]: row["action"] = "SKIP" elif current and _append_compatible(current.data_json, values): row["action"] = "UPDATE" else: row["action"], row["reason"] = "CONFLICT", "FORMAL_SOURCE_CHANGED" else: row["action"] = "CREATE" for entity, ids in ( ("shipments", values["group"]["shipment_ids"]), ("receipts", values["group"]["receipt_ids"]), ): for source_id in ids: linked = _link(session, run.source_database, entity, source_id) source_rows = values[entity] excluded = all(r.get("exclusion") or r.get("performance_excluded") for r in source_rows if r["source_id"] == source_id) if linked and not excluded and ( not link or not current or source_id not in current.data_json["maps"].get(entity, {}) ): row["action"], row["reason"] = "CONFLICT", "SOURCE_ALREADY_IMPORTED" if not _link(session, run.source_database, "customers", values["group"]["customer_id"]): row["action"], row["reason"] = "PENDING", "CUSTOMER_IMPORT_REQUIRED" for ship in active_ships: if not _link(session, run.source_database, "products", ship["product_id"]): row["action"], row["reason"] = "PENDING", "PRODUCT_IMPORT_REQUIRED" frozen = {"calculation": frozen, "plan": rows, "targets": targets} return rows, frozen, digest(frozen) def _add_link(session, source, entity, source_id, target): session.add( TPlusSourceLink( source_database=source, entity_type=entity, source_id=str(source_id), target_id=target.id, ) ) def _new_no(session, model, column, prefix): return next_no(session, model=model, column_name=column, prefix=prefix) def _create(session, run, row, frozen): values = copy.deepcopy(frozen["calculation"]["calculation_inputs"][row["source_id"]]) people = {} for code in values["facts"]["personnel"]: candidates = list( session.scalars( select(Personnel) .join(TPlusSourceLink, TPlusSourceLink.target_id == Personnel.id) .where( TPlusSourceLink.source_database == run.source_database, TPlusSourceLink.entity_type == "personnel", Personnel.source_no == code, ) ) ) if len(candidates) == 1: people[code] = candidates[0] active_ships = [s for s in values["shipments"] if not s.get("performance_excluded")] needed = {str(s["personnel_id"]) for s in active_ships} needed.update(str(s["manager_id"]) for s in active_ships if s.get("manager_id")) for handover in values["facts"]["handovers"]: needed.update((str(handover["from_personnel_id"]), str(handover["to_personnel_id"]))) if needed - people.keys(): raise WorkbenchError("PERSONNEL_IMPORT_REQUIRED") customer = _link(session, run.source_database, "customers", values["group"]["customer_id"]) old_input = _latest_input(session, row["target_id"]) if row.get("target_id") else None business = ( session.get(Business, row["target_id"], with_for_update=True) if row.get("target_id") else Business( business_no=_new_no(session, Business, "business_no", "YW"), customer_id=customer.target_id, owner_personnel_id=people[str(active_ships[0]["personnel_id"])].id, business_date=min(date.fromisoformat(s["date"]) for s in active_ships), status=1, is_special=0, t_plus_id=row["business_key"], source_no=row["business_key"], sync_batch_no=run.run_no, synced_at=datetime.now(), ) ) session.add(business) session.flush() if old_input is None: _add_link(session, run.source_database, "businesses", row["business_key"], business) maps = ( copy.deepcopy(old_input.data_json["maps"]) if old_input else {"shipments": {}, "shipment_items": {}, "receipts": {}, "personnel": {}} ) maps["personnel"].update({code: p.id for code, p in people.items()}) freight_total, sales_total, cost_total = Decimal(0), Decimal(0), Decimal(0) for source_id in values["group"]["shipment_ids"]: lines = [s for s in values["shipments"] if s["source_id"] == source_id] if not lines: continue if any(s.get("performance_excluded") for s in lines): values.setdefault("excluded_corrections", []).append( {"shipment_id": source_id, "lines": lines} ) continue head = lines[0] freight = sum((money(s.get("freight_amount") or "0") for s in lines), Decimal(0)) if source_id in maps["shipments"]: existing = session.get(Shipment, maps["shipments"][source_id], with_for_update=True) if ( existing is None or existing.total_amount != sum((money(s["amount"]) for s in lines), Decimal(0)) or existing.ship_date.isoformat() != head["date"] ): raise WorkbenchError("FORMAL_TARGET_CHANGED") sales_total += existing.total_amount freight_total += existing.freight_amount cost_total += sum( (money(s["cost_total"]) for s in lines if s.get("cost_total") is not None), Decimal(0), ) continue shipment = Shipment( business_id=business.id, shipment_no=_new_no(session, Shipment, "shipment_no", "SH"), ship_date=date.fromisoformat(head["date"]), total_amount=sum((money(s["amount"]) for s in lines), Decimal(0)), freight_amount=freight, source_no=head["source_no"], t_plus_id=source_id, sync_batch_no=run.run_no, synced_at=datetime.now(), ) session.add(shipment) session.flush() maps["shipments"][source_id] = shipment.id _add_link(session, run.source_database, "shipments", source_id, shipment) for ship in lines: product = session.get( Product, _link(session, run.source_database, "products", ship["product_id"]).target_id, ) quantity = money(ship["quantity"]) amount = money(ship["amount"]) cost = money(ship["cost_total"]) if ship.get("cost_total") is not None else None item = ShipmentItem( shipment_id=shipment.id, product_id=product.id, product_name=product.product_name, spec_model=product.spec_model or "", unit=ship["unit"], quantity=quantity, sale_price=(amount / quantity).quantize(Decimal("0.0001")), amount=amount, ) session.add(item) session.flush() maps["shipment_items"][ship["line_id"]] = item.id session.add( BusinessItem( business_id=business.id, product_id=product.id, product_name=product.product_name, spec_model=product.spec_model or "", unit=ship["unit"], quantity=quantity, sale_price=item.sale_price, sale_amount=amount, cost_amount=cost, product_category=ship["category"], ) ) sales_total += amount cost_total += cost or Decimal(0) freight_total += freight received = Decimal(0) for cash in values["receipts"]: if cash.get("exclusion"): continue if cash["source_id"] in maps["receipts"]: existing = session.get( Receipt, maps["receipts"][cash["source_id"]], with_for_update=True ) if ( existing is None or existing.amount != money(cash["amount"]) or existing.receipt_date.isoformat() != cash["date"] ): raise WorkbenchError("FORMAL_TARGET_CHANGED") received += existing.amount continue receipt = Receipt( business_id=business.id, receipt_batch_no=_new_no(session, Receipt, "receipt_batch_no", "HK"), receipt_date=date.fromisoformat(cash["date"]), amount=money(cash["amount"]), is_barter=0, settlement_status=0, source_no=cash["source_no"], t_plus_id=cash["source_id"], sync_batch_no=run.run_no, synced_at=datetime.now(), ) session.add(receipt) session.flush() maps["receipts"][cash["source_id"]] = receipt.id _add_link(session, run.source_database, "receipts", cash["source_id"], receipt) received += receipt.amount business.total_sale_amount = sales_total business.total_ship_amount = sales_total business.total_cost_amount = cost_total business.total_received_amount = received business.total_freight_amount = freight_total values["maps"] = maps values["source_facts"] = frozen["calculation"]["facts"] session.add( PayrollInput( business_id=business.id, version=old_input.version + 1 if old_input else 1, source_database=run.source_database, group_key=row["source_id"], fingerprint=row["input_hash"], data_json=values, requested_by=run.requested_by, ) ) row["target_id"] = business.id def execute_business(session, run): operator = session.get(User, run.requested_by) require_permission(session, operator, "tplus:import" if run.kind == "BUSINESS_APPLY" else "tplus:preview") request = run if run.kind == "BUSINESS_APPLY": request = get_run(session, run.input_json["preview_no"], run.source_database) if request.kind != "BUSINESS_PREVIEW" or request.status not in ("SUCCEEDED", "PARTIAL"): raise WorkbenchError("PREVIEW_REQUIRED") payroll_months.ensure_allocation_editable(session, request.input_json["month"], operator, require_maker=False) rows, frozen, fingerprint = _build(session, request) if run.kind == "BUSINESS_APPLY" and request.snapshot_hash != fingerprint: raise WorkbenchError("PREVIEW_STALE") counts = Counter() for i, row in enumerate(rows, 1): if run.kind == "BUSINESS_APPLY" and row["action"] in ("CREATE", "UPDATE"): try: with session.begin_nested(): _create(session, run, row, frozen) except WorkbenchError as exc: row["action"], row["reason"] = "PENDING", str(exc) counts[row["action"]] += 1 session.add( TPlusWorkLine( run_no=run.run_no, line_no=i, entity_type="businesses", source_id=row["source_id"], action=row["action"], reason=row.get("reason"), data_json=row, ) ) run.snapshot_json, run.snapshot_hash = frozen, fingerprint run.summary_json = { "counts": dict(counts), "total": len(rows), "formal_business_written": run.kind == "BUSINESS_APPLY", "formal_payroll_written": False, } run.status = "PARTIAL" if counts["PENDING"] or counts["CONFLICT"] else "SUCCEEDED"