feat: Web 文件上传、免登录访客隔离与锐扬试排修复
- Web 端支持本机工程数据文件上传并自动落位项目目录 - 免登录云部署按浏览器访客 ID + HttpOnly Cookie 隔离项目/会话/个人世界 - 修复设置页偏好接口返回结构导致的 Object.keys 白屏 - 修复工程目录试排 0 虚拟产线:非排产 sheet 跳过、物料 id 冲突、试排走 PoolEngine - 补充黄金测试:文件上传、访客隔离、trial 试排、目录跳过、物料 id
This commit is contained in:
parent
3997809acb
commit
ef7256f12c
|
|
@ -94,6 +94,10 @@ npm run build:desktop # Electron 安装包 → apps/desktop/release
|
|||
3. 「试排一版交期优先」/ 「发布这个版本」
|
||||
4. `pytest tests/golden -q`(当前期望全绿,见 CHANGELOG 最新验证数)
|
||||
|
||||
Web 端读取本机数据文件:在项目面板「文件」区点「上传」,选择本机的
|
||||
`.xlsx/.xlsm/.csv/.txt/.sql`(可多选);上传后自动作为该项目的工程目录,
|
||||
对话中说「分析一下数据文件」即可走同一套目录分析与排产链路。
|
||||
|
||||
完整旅程说明:[docs/product/journeys.md](docs/product/journeys.md)
|
||||
|
||||
## 大模型接入
|
||||
|
|
|
|||
|
|
@ -30,6 +30,24 @@ export function getClientMode(): ClientMode {
|
|||
return window.apsDesktop?.mode === 'desktop' ? 'desktop' : 'web';
|
||||
}
|
||||
|
||||
const ANONYMOUS_VISITOR_KEY = 'aps.visitor.id.v1';
|
||||
let memoryVisitorId: string | null = null;
|
||||
|
||||
function ensureVisitorId(): string {
|
||||
try {
|
||||
const existing = window.localStorage.getItem(ANONYMOUS_VISITOR_KEY);
|
||||
if (existing && existing.length >= 8) return existing;
|
||||
const next = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
|
||||
? crypto.randomUUID().replace(/-/g, '')
|
||||
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
window.localStorage.setItem(ANONYMOUS_VISITOR_KEY, next);
|
||||
return next;
|
||||
} catch {
|
||||
memoryVisitorId ??= `mem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
return memoryVisitorId;
|
||||
}
|
||||
}
|
||||
|
||||
let desktopDeviceId: Promise<string> | null = null;
|
||||
|
||||
async function getDesktopDeviceId(): Promise<string> {
|
||||
|
|
@ -43,6 +61,8 @@ export async function clientFetch(input: RequestInfo | URL, init: RequestInit =
|
|||
if (getClientMode() === 'desktop') {
|
||||
headers.set('X-APS-Client', 'desktop');
|
||||
headers.set('X-APS-Device-ID', await getDesktopDeviceId());
|
||||
} else {
|
||||
headers.set('X-APS-Visitor-ID', ensureVisitorId());
|
||||
}
|
||||
return window.fetch(input, { ...init, headers, credentials: 'include' });
|
||||
}
|
||||
|
|
@ -631,7 +651,12 @@ export const fetchGoldenTests = (run = false) =>
|
|||
// LLM Provider 状态
|
||||
export const fetchLlmSettings = () => getJson<LlmSettings>('/api/settings/llm');
|
||||
export const fetchPreferenceExplain = (projectId?: string) =>
|
||||
getJson<PreferenceExplain>(`/api/settings/preferences${projectId ? `?project_id=${encodeURIComponent(projectId)}` : ''}`);
|
||||
getJson<{ explain: PreferenceExplain; projectId?: string | null; maxSamples?: number }>(
|
||||
`/api/settings/preferences${projectId ? `?project_id=${encodeURIComponent(projectId)}` : ''}`,
|
||||
).then(r => {
|
||||
if (!r.explain) throw new ApiError(200, 'preferences explain 缺失');
|
||||
return { ...r.explain, scores: r.explain.scores ?? {} };
|
||||
});
|
||||
|
||||
export async function resetPreferences(projectId?: string) {
|
||||
const resp = await apiFetch('/api/settings/preferences/reset', {
|
||||
|
|
@ -884,6 +909,24 @@ export const createWorkspaceFile = (file: {
|
|||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(file),
|
||||
});
|
||||
|
||||
export const uploadProjectFiles = (projectId: string, files: File[]) => {
|
||||
const form = new FormData();
|
||||
for (const file of files) form.append('files', file, file.name);
|
||||
return apiFetch(`/api/projects/${encodeURIComponent(projectId)}/files/upload`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
}).then(async response => {
|
||||
if (!response.ok) throw new ApiError(response.status, await response.text());
|
||||
return response.json() as Promise<{
|
||||
message?: string;
|
||||
saved?: string[];
|
||||
errors?: { name: string; error: string }[];
|
||||
workDir?: string;
|
||||
workspace?: unknown;
|
||||
}>;
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteWorkspaceFile = (fileId: string) =>
|
||||
workspaceMutation(`/api/project-files/${encodeURIComponent(fileId)}`, { method: 'DELETE' });
|
||||
|
||||
|
|
|
|||
|
|
@ -789,9 +789,9 @@ function PersonalView() {
|
|||
<div className="gov-kv">{explain.reason}(有效样本 {explain.sampleCount} / 总样本 {explain.totalCount})</div>
|
||||
<div className="gov-kv">
|
||||
得分明细:
|
||||
{Object.keys(explain.scores).length === 0
|
||||
{Object.keys(explain.scores ?? {}).length === 0
|
||||
? ' 暂无历史信号'
|
||||
: Object.entries(explain.scores)
|
||||
: Object.entries(explain.scores ?? {})
|
||||
.map(([k, v]) => `${k} ${v}分`)
|
||||
.join(' · ')}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import {
|
|||
deleteWorkspaceSession,
|
||||
renameWorkspaceProject,
|
||||
renameWorkspaceSession,
|
||||
uploadProjectFiles,
|
||||
} from '../api/client';
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
|
|
@ -183,6 +184,9 @@ export default function ProjectPanel(props: {
|
|||
const [projectDlgOpen, setProjectDlgOpen] = useState(false);
|
||||
const [confirmDlg, setConfirmDlg] = useState<ConfirmKind | null>(null);
|
||||
const [memberProject, setMemberProject] = useState<Project | null>(null);
|
||||
const uploadRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadNote, setUploadNote] = useState('');
|
||||
|
||||
const activeProject = useMemo(
|
||||
() => data.projects.find(p => p.id === data.activeProjectId) ?? null,
|
||||
|
|
@ -225,6 +229,25 @@ export default function ProjectPanel(props: {
|
|||
setNewTaskOpen(false);
|
||||
};
|
||||
|
||||
const onPickUpload = async (files: FileList | null) => {
|
||||
const picked = Array.from(files ?? []);
|
||||
if (!picked.length || !activeProject) return;
|
||||
setUploading(true);
|
||||
setUploadNote('');
|
||||
try {
|
||||
const res = await uploadProjectFiles(activeProject.id, picked);
|
||||
const failed = (res.errors ?? []).filter(item => item.error);
|
||||
setUploadNote(failed.length
|
||||
? `${res.message ?? '上传完成'};${failed.length} 个失败`
|
||||
: (res.message ?? `已上传 ${res.saved?.length ?? 0} 个文件`));
|
||||
props.onRefresh();
|
||||
} catch (err) {
|
||||
setUploadNote(err instanceof Error ? err.message : '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onMenu = (e: Event) => {
|
||||
const action = (e as CustomEvent<{ action?: string }>).detail?.action;
|
||||
|
|
@ -475,12 +498,36 @@ export default function ProjectPanel(props: {
|
|||
<div className="sidebar-section sidebar-file-section">
|
||||
<div className="sidebar-section-head">
|
||||
<span>文件</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-btn"
|
||||
onClick={() => activeProject && setNameDlg({ mode: 'new-file', projectId: activeProject.id })}
|
||||
>登记</button>
|
||||
<input
|
||||
ref={uploadRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".xlsx,.xlsm,.csv,.txt,.sql"
|
||||
hidden
|
||||
onChange={e => { void onPickUpload(e.target.files); e.target.value = ''; }}
|
||||
/>
|
||||
<div className="sidebar-file-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="text-btn"
|
||||
disabled={uploading}
|
||||
onClick={() => uploadRef.current?.click()}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<path d="m17 8-5-5-5 5" /><path d="M12 3v12" />
|
||||
</svg>
|
||||
上传
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-btn"
|
||||
onClick={() => activeProject && setNameDlg({ mode: 'new-file', projectId: activeProject.id })}
|
||||
>登记</button>
|
||||
</div>
|
||||
</div>
|
||||
{uploadNote && <div className="sidebar-upload-note">{uploadNote}</div>}
|
||||
<div className="sidebar-list">
|
||||
{projectFiles.length === 0 && <div className="sidebar-empty">暂无文件</div>}
|
||||
{projectFiles.map(f => (
|
||||
|
|
|
|||
|
|
@ -906,6 +906,11 @@ html.aps-desktop .app-menubar-spacer,
|
|||
.text-btn { border: none; background: transparent; color: var(--accent-deep); font-size: 12px;
|
||||
cursor: pointer; font-weight: 600; padding: 3px 8px; border-radius: 8px; }
|
||||
.text-btn:hover { background: var(--accent-soft); }
|
||||
.text-btn:disabled { opacity: .45; cursor: not-allowed; }
|
||||
.text-btn svg { flex: 0 0 auto; }
|
||||
.sidebar-file-actions { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.sidebar-upload-note { font-size: 11px; color: var(--accent-deep); line-height: 1.35;
|
||||
padding: 2px 0 6px; word-break: break-all; }
|
||||
.icon-btn { border: none; background: transparent; color: var(--muted);
|
||||
width: 28px; height: 28px; border-radius: 8px; cursor: pointer;
|
||||
display: inline-flex; align-items: center; justify-content: center; flex: 0 0 28px; }
|
||||
|
|
|
|||
|
|
@ -210,6 +210,7 @@
|
|||
| `POST /api/rush/stage` | 采用紧急插单生成确认卡 | `rush.apply` P2 → `/api/actions/confirm` 执行 | OR-04 |
|
||||
| `POST /api/forecast/stage` | 预测新建/删除/转正确认卡 | `forecast.*` P2 → `/api/actions/confirm` 执行 | OR-05 |
|
||||
| `POST /api/projects` · `DELETE /api/projects/{id}` · `POST /api/sessions` | 项目/会话元数据(P1 直通) | `project.*` / `session.create` | AG-08 |
|
||||
| `POST /api/projects/{id}/files/upload` | 浏览器上传本地工程数据文件到项目目录(P1 直通) | `project.*` | AG-08 |
|
||||
| `POST /api/master/stage` | 产线/物料/维保编辑生成确认卡 | `master.*` P2 → `/api/actions/confirm` 执行 | M4 首切片 |
|
||||
| `POST /api/import/commit` | 导入批次生成确认卡 | `import.commit` P2 → `/api/actions/confirm` 执行 | MD-04 |
|
||||
|
||||
|
|
|
|||
|
|
@ -232,13 +232,15 @@ def flex_gantt_view(world: World) -> dict:
|
|||
def run_flex_schedule(store, sort_mode: str | None = None, order_ids: list[int] | None = None,
|
||||
start_date: str | None = None, name: str | None = None,
|
||||
actor: str = "web", window: str | None = None,
|
||||
enforce_teams: bool | None = None) -> dict:
|
||||
enforce_teams: bool | None = None,
|
||||
trial: bool = False) -> dict:
|
||||
"""Run the governed closed-loop scheduling pipeline as one P1 action.
|
||||
|
||||
Real/site worlds always use the closed-loop requirement, supply, routing and
|
||||
resource admission flow. The legacy direct PoolEngine path is retained only
|
||||
for an explicitly constructed world carrying the demo factory marker, so golden demo fixtures remain isolated from product
|
||||
semantics.
|
||||
semantics. ``trial=True`` uses the PoolEngine capability-pool draft for
|
||||
folder-schedule quick trials, leaving strict admission for formal runs.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
|
|
@ -270,7 +272,7 @@ def run_flex_schedule(store, sort_mode: str | None = None, order_ids: list[int]
|
|||
)
|
||||
|
||||
def execute_pipeline() -> dict:
|
||||
if explicit_demo:
|
||||
if explicit_demo or trial:
|
||||
result = PoolEngine().solve(
|
||||
store.data,
|
||||
store.next_id,
|
||||
|
|
@ -281,7 +283,7 @@ def run_flex_schedule(store, sort_mode: str | None = None, order_ids: list[int]
|
|||
window=window,
|
||||
enforce_teams=enforce_teams,
|
||||
)
|
||||
result["executionMode"] = "EXPLICIT_DEMO_LEGACY"
|
||||
result["executionMode"] = "POOL_TRIAL" if trial else "EXPLICIT_DEMO_LEGACY"
|
||||
synced = sync_flex_orders_to_sales(store.data)
|
||||
annotate_world_sourcing(store.data)
|
||||
decomposition = decompose_orders(store.data, store.next_id)
|
||||
|
|
@ -318,7 +320,10 @@ def run_flex_schedule(store, sort_mode: str | None = None, order_ids: list[int]
|
|||
"window": window,
|
||||
"businessDate": business_date,
|
||||
"scheduleStartDate": start_date,
|
||||
"executionMode": "EXPLICIT_DEMO_LEGACY" if explicit_demo else "CLOSED_LOOP_V1",
|
||||
"executionMode": (
|
||||
"POOL_TRIAL" if trial
|
||||
else ("EXPLICIT_DEMO_LEGACY" if explicit_demo else "CLOSED_LOOP_V1")
|
||||
),
|
||||
},
|
||||
execute_pipeline,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -42,7 +42,11 @@ _REQUIRED = ("orders", "materials", "routing", "equipment")
|
|||
_HELPFUL = ("bom", "molds", "operations")
|
||||
|
||||
# 锐扬 MOM 收集表等现场模板中的非排产块:不导入,其问题行不计为数据错误
|
||||
_MOM_SKIP_BLOCKS = ("生产模型", "质检方案", "仓储模型", "人力资源", "客户管理", "供应商管理")
|
||||
_MOM_SKIP_BLOCKS = (
|
||||
"生产模型", "质检方案", "仓储模型", "人力资源", "客户管理", "供应商管理",
|
||||
"数据说明", "工厂资源", "人员技能", "客户供应商", "在制任务",
|
||||
"排产参数", "插单场景", "数据校验",
|
||||
)
|
||||
|
||||
|
||||
def _is_mom_skip_block(sheet: str | None) -> bool:
|
||||
|
|
@ -310,28 +314,27 @@ def analyze_work_dir(
|
|||
|
||||
for b in preview.get("batches") or []:
|
||||
kind = b.get("kind") or primary_kind or "materials"
|
||||
if not primary_kind:
|
||||
primary_kind = kind
|
||||
cover_kind = primary_kind or kind
|
||||
n_ok = int(b.get("okCount") or 0)
|
||||
if n_ok:
|
||||
kind_ok[cover_kind] = kind_ok.get(cover_kind, 0) + n_ok
|
||||
if kind != cover_kind:
|
||||
kind_ok[kind] = kind_ok.get(kind, 0) + n_ok
|
||||
|
||||
if not field_map and b.get("fieldMap"):
|
||||
field_map = list(b["fieldMap"])
|
||||
if primary_kind and b.get("fieldMap"):
|
||||
kind_fields.setdefault(primary_kind, list(b["fieldMap"]))
|
||||
|
||||
is_skip = _is_mom_skip_block(b.get("sheet"))
|
||||
original_error_count = int(b.get("errorCount") or 0)
|
||||
if not is_skip:
|
||||
if not primary_kind:
|
||||
primary_kind = kind
|
||||
cover_kind = primary_kind or kind
|
||||
if n_ok:
|
||||
kind_ok[cover_kind] = kind_ok.get(cover_kind, 0) + n_ok
|
||||
if kind != cover_kind:
|
||||
kind_ok[kind] = kind_ok.get(kind, 0) + n_ok
|
||||
if not field_map and b.get("fieldMap"):
|
||||
field_map = list(b["fieldMap"])
|
||||
if primary_kind and b.get("fieldMap"):
|
||||
kind_fields.setdefault(primary_kind, list(b["fieldMap"]))
|
||||
batch_diagnostics = [
|
||||
dict(item) for item in (b.get("diagnostics") or [])
|
||||
if isinstance(item, dict)
|
||||
]
|
||||
if is_skip:
|
||||
skip_total += original_error_count
|
||||
skip_total += n_ok + original_error_count
|
||||
if not batch_diagnostics:
|
||||
batch_diagnostics = [
|
||||
{
|
||||
|
|
@ -383,8 +386,9 @@ def analyze_work_dir(
|
|||
"skip": is_skip,
|
||||
"diagnosticCounts": batch_counts,
|
||||
})
|
||||
ok_total += n_ok
|
||||
err_total += real_err
|
||||
if not is_skip:
|
||||
ok_total += n_ok
|
||||
err_total += real_err
|
||||
for row in (b.get("okRows") or [])[:2]:
|
||||
if isinstance(row, dict):
|
||||
samples.append(_sample_cells(row))
|
||||
|
|
@ -502,7 +506,11 @@ def analyze_work_dir(
|
|||
missing=missing, soft_missing=soft_missing,
|
||||
can_schedule=can_schedule, kind_ok=kind_ok, world_ready=world_ready,
|
||||
)
|
||||
excel_ok = sum(int(b.get("okCount") or 0) for b in all_batches)
|
||||
excel_ok = sum(
|
||||
int(b.get("okCount") or 0)
|
||||
for b in all_batches
|
||||
if not b.get("skip")
|
||||
)
|
||||
sql_ok_n = int((file_reports[0].get("okCount") or 0) if sql_applied and file_reports else 0)
|
||||
diagnostic_counts = {
|
||||
severity: sum(
|
||||
|
|
@ -518,7 +526,7 @@ def analyze_work_dir(
|
|||
"kindCounts": kind_ok,
|
||||
"missing": missing, "softMissing": soft_missing,
|
||||
"canSchedule": can_schedule,
|
||||
"batches": [b for b in all_batches if b.get("okRows")],
|
||||
"batches": [b for b in all_batches if b.get("okRows") and not b.get("skip")],
|
||||
"totalOk": excel_ok + sql_ok_n,
|
||||
"totalErrors": sum(int(b.get("errorCount") or 0) for b in all_batches),
|
||||
"skippedRows": sum(int(f.get("skipRows") or 0) for f in file_reports),
|
||||
|
|
|
|||
|
|
@ -993,7 +993,12 @@ def apply_import_commit(world: World, next_id, batches: list[dict[str, Any]]) ->
|
|||
(item for item in world.setdefault("materials", []) if item.get("code") == row.get("code")),
|
||||
None,
|
||||
)
|
||||
master_row = {**row, **({"id": existing_material["id"]} if existing_material else {})}
|
||||
if existing_material:
|
||||
master_row = {**row, "id": existing_material["id"]}
|
||||
else:
|
||||
# 订单先入库时 sync_flex_orders_to_sales 可能已直接建成品,
|
||||
# 外部发号器仍停在 0,直接 create 会让原材料与成品 id 冲突。
|
||||
master_row = {**row, "newId": _next_table_id("materials")}
|
||||
apply_master_action(world, next_id, "master.material.upsert", master_row)
|
||||
fm = world.setdefault("flexMaterials", [])
|
||||
ex = next((m for m in fm if m["code"] == row["code"]), None)
|
||||
|
|
@ -1007,7 +1012,8 @@ def apply_import_commit(world: World, next_id, batches: list[dict[str, Any]]) ->
|
|||
**{k: row.get(k) for k in
|
||||
("code", "name", "type", "unit", "stock", "inTransit",
|
||||
"safetyStock", "procurementLeadTime", "expectedArrivalDate", "sourcingType")}})
|
||||
summary["materials"] = summary.get("materials", 0) + 1
|
||||
if existing_material is None:
|
||||
summary["materials"] = summary.get("materials", 0) + 1
|
||||
elif kind == "calendar":
|
||||
items = world.setdefault("flexCalendar", [])
|
||||
ex = next((item for item in items if item.get("shiftCode") == row.get("shiftCode")), None)
|
||||
|
|
|
|||
|
|
@ -355,6 +355,7 @@ def normalize_material_payload(world: World, payload: dict[str, Any]) -> dict[st
|
|||
"procurementLeadTime": int(float(payload.get("procurementLeadTime") or 0)),
|
||||
"productFamily": str(payload.get("productFamily") or "").strip(),
|
||||
"status": "ACTIVE",
|
||||
"newId": int(payload.get("newId") or 0),
|
||||
}
|
||||
for field in ("stock", "inTransit", "safetyStock"):
|
||||
if out[field] < 0:
|
||||
|
|
@ -794,7 +795,7 @@ def apply_master_action(world: World, next_id, action: str, payload: dict[str, A
|
|||
if action == "master.material.upsert":
|
||||
p = normalize_material_payload(world, payload)
|
||||
if p["op"] == "create":
|
||||
mid = next_id("material")
|
||||
mid = int(p.get("newId") or 0) or next_id("material")
|
||||
row = {
|
||||
"id": mid, "code": p["code"], "name": p["name"], "spec": p["spec"],
|
||||
"type": p["type"], "unit": p["unit"],
|
||||
|
|
|
|||
|
|
@ -895,7 +895,7 @@ def execute_confirmed(store: WorldStore, confirm_id: str, approve: bool, actor:
|
|||
return head + "\n\n导入后仍不具备开排条件:\n" + readiness_text(ready)
|
||||
from server.aps_domain.flex import run_flex_schedule
|
||||
result = run_flex_schedule(
|
||||
store, sort_mode=params.get("sortMode") or "BOTTLENECK", actor=actor)
|
||||
store, sort_mode=params.get("sortMode") or "BOTTLENECK", actor=actor, trial=True)
|
||||
ver = (result or {}).get("versionNo") or ""
|
||||
return (head + f"\n已按目录数据试排一版:**{ver}**"
|
||||
f"(虚拟产线 {(result or {}).get('vlCount', 0)} · "
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
|
||||
from fastapi import Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.datastructures import MutableHeaders
|
||||
|
||||
from server.auth.context import IdentityContext, bind_identity, reset_identity
|
||||
from server.auth.licenses import get_license_provider, is_desktop_request
|
||||
|
|
@ -29,6 +32,9 @@ SELF_AUTHORIZED_WRITE_PREFIXES = (
|
|||
"/api/drawings", # 图纸解析只读放行;候选入库走 P2 门禁
|
||||
)
|
||||
|
||||
ANONYMOUS_COOKIE = "aps_anonymous_id"
|
||||
ANONYMOUS_COOKIE_MAX_AGE = 365 * 24 * 3600
|
||||
|
||||
|
||||
def authentication_enabled() -> bool:
|
||||
"""Return whether browser/desktop requests must present login credentials."""
|
||||
|
|
@ -48,6 +54,40 @@ def bypass_identity() -> IdentityContext:
|
|||
)
|
||||
|
||||
|
||||
def _visitor_identity(visitor: str) -> IdentityContext | None:
|
||||
visitor = str(visitor or "").strip().lower()
|
||||
if len(visitor) < 8 or len(visitor) > 128 or not re.fullmatch(r"[a-z0-9_-]+", visitor):
|
||||
return None
|
||||
digest = hashlib.sha256(f"aps-anonymous:{visitor}".encode("utf-8")).hexdigest()
|
||||
user_id = int(digest[:15], 16)
|
||||
return IdentityContext(
|
||||
user_id=user_id,
|
||||
username=f"anon-{digest[:10]}",
|
||||
fullname=f"访客 {digest[:8]}",
|
||||
tenant_uuid="platform",
|
||||
roles=("system", "admin", "planner", "approver", "auditor", "scheduler", "desktop"),
|
||||
auth_kind="anonymous",
|
||||
)
|
||||
|
||||
|
||||
def anonymous_visitor_identity(request: Request) -> tuple[IdentityContext, str | None]:
|
||||
"""免登录云部署:按浏览器访客 ID 派生独立用户作用域。
|
||||
|
||||
Web 前端把 localStorage 里稳定的访客 ID 随 X-APS-Visitor-ID 发送;
|
||||
后端用同一 ID 派生出固定 user_id,并签发长期 HttpOnly Cookie。
|
||||
之后即使 localStorage 被清空,Cookie 仍能让浏览器找回同一身份。
|
||||
没有 Cookie/头的旧客户端回退到本地管理员身份,保持既有行为。
|
||||
"""
|
||||
identity = _visitor_identity(request.cookies.get(ANONYMOUS_COOKIE))
|
||||
if identity is not None:
|
||||
return identity, None
|
||||
visitor = request.headers.get("x-aps-visitor-id")
|
||||
identity = _visitor_identity(visitor)
|
||||
if identity is not None:
|
||||
return identity, str(visitor).strip().lower()
|
||||
return bypass_identity(), None
|
||||
|
||||
|
||||
class AuthenticationMiddleware:
|
||||
"""ASGI middleware keeps identity bound for the full streaming response lifetime."""
|
||||
|
||||
|
|
@ -62,8 +102,9 @@ class AuthenticationMiddleware:
|
|||
if scope.get("method") == "OPTIONS" or not path.startswith("/api/") or path in PUBLIC_API_PATHS:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
request = Request(scope, receive=receive)
|
||||
anon_cookie: str | None = None
|
||||
if authentication_enabled():
|
||||
request = Request(scope, receive=receive)
|
||||
try:
|
||||
provider = get_license_provider() if is_desktop_request(request) else get_auth_provider()
|
||||
identity = await provider.authenticate(request)
|
||||
|
|
@ -75,7 +116,7 @@ class AuthenticationMiddleware:
|
|||
await response(scope, receive, send)
|
||||
return
|
||||
else:
|
||||
identity = bypass_identity()
|
||||
identity, anon_cookie = anonymous_visitor_identity(request)
|
||||
token = bind_identity(identity)
|
||||
try:
|
||||
method = scope.get("method") or "GET"
|
||||
|
|
@ -90,6 +131,20 @@ class AuthenticationMiddleware:
|
|||
)
|
||||
await response(scope, receive, send)
|
||||
return
|
||||
await self.app(scope, receive, send)
|
||||
if anon_cookie:
|
||||
secure = "; Secure" if request.url.scheme == "https" else ""
|
||||
cookie_value = (
|
||||
f"{ANONYMOUS_COOKIE}={anon_cookie}; Path=/; "
|
||||
f"Max-Age={ANONYMOUS_COOKIE_MAX_AGE}; HttpOnly; SameSite=Lax{secure}"
|
||||
)
|
||||
|
||||
async def send_with_cookie(message) -> None:
|
||||
if message["type"] == "http.response.start":
|
||||
MutableHeaders(scope=message).append("set-cookie", cookie_value)
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_with_cookie)
|
||||
else:
|
||||
await self.app(scope, receive, send)
|
||||
finally:
|
||||
reset_identity(token)
|
||||
|
|
|
|||
|
|
@ -2942,6 +2942,7 @@ def create_app() -> FastAPI:
|
|||
return {"message": "多智能体编排已恢复起始状态", "state": state}
|
||||
|
||||
# ---------------- AG-08 项目 / 会话工作区(续) ----------------
|
||||
# POST /api/projects/{id}/files/upload 浏览器上传本地工程数据文件(P1)
|
||||
@app.get("/api/workspace")
|
||||
async def workspace_get() -> dict:
|
||||
"""工作区快照(P0):项目/会话/文件/消息 + worldKey 绑定。"""
|
||||
|
|
@ -3241,6 +3242,82 @@ def create_app() -> FastAPI:
|
|||
except (ValueError, PermissionError) as exc:
|
||||
raise HTTPException(status_code=403, detail=str(exc)) from exc
|
||||
|
||||
_PROJECT_UPLOAD_EXTS = {".xlsx", ".xlsm", ".csv", ".txt", ".sql"}
|
||||
_PROJECT_UPLOAD_MAX_BYTES = 100 * 1024 * 1024
|
||||
|
||||
def _project_upload_name(filename: str) -> str:
|
||||
name = (filename or "").replace("\\", "/").rsplit("/", 1)[-1].strip()
|
||||
return name[:160]
|
||||
|
||||
@app.post("/api/projects/{project_id}/files/upload")
|
||||
async def project_files_upload(project_id: str, files: list[UploadFile] = File(...)) -> dict:
|
||||
"""浏览器上传本地工程数据文件(P1):写入项目目录并登记文件。
|
||||
|
||||
项目没有 workDir 时自动落到服务端项目数据目录,目录分析/文件夹排产可直接读取。
|
||||
"""
|
||||
from server.aps_home import data_dir
|
||||
from server.state.projects import get_project_store
|
||||
|
||||
uploads = [f for f in files if (f.filename or "").strip()]
|
||||
if not uploads:
|
||||
raise HTTPException(status_code=400, detail="没有选择文件")
|
||||
bad = [f.filename for f in uploads
|
||||
if Path(f.filename or "").suffix.lower() not in _PROJECT_UPLOAD_EXTS]
|
||||
if bad:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"仅支持 .xlsx/.xlsm/.csv/.txt/.sql:{', '.join(str(x) for x in bad)}",
|
||||
)
|
||||
|
||||
ps = get_project_store()
|
||||
snap = ps.snapshot(include_messages=False)
|
||||
project = next((p for p in snap.get("projects") or [] if p.get("id") == project_id), None)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=404, detail="项目不存在或无权访问")
|
||||
|
||||
work = (project.get("workDir") or "").strip()
|
||||
if not work:
|
||||
work = str(data_dir() / "project-files" / project_id)
|
||||
ps.set_work_dir(project_id, work)
|
||||
try:
|
||||
Path(work).mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=400, detail=f"工程目录不可访问:{exc}") from exc
|
||||
|
||||
existing = {f["name"]: f["id"] for f in (snap.get("files") or [])
|
||||
if f.get("projectId") == project_id}
|
||||
saved: list[str] = []
|
||||
errors: list[dict[str, str]] = []
|
||||
for upload in uploads:
|
||||
name = _project_upload_name(upload.filename or "upload")
|
||||
if not name or name in {".", ".."}:
|
||||
errors.append({"name": upload.filename or "", "error": "文件名无效"})
|
||||
continue
|
||||
target = Path(work) / name
|
||||
written = 0
|
||||
try:
|
||||
with target.open("wb") as handle:
|
||||
while chunk := upload.file.read(1024 * 1024):
|
||||
written += len(chunk)
|
||||
if written > _PROJECT_UPLOAD_MAX_BYTES:
|
||||
raise ValueError("文件超过 100MB 上限")
|
||||
handle.write(chunk)
|
||||
old_id = existing.get(name)
|
||||
if old_id:
|
||||
ps.delete_file(old_id)
|
||||
ps.create_file(project_id, name, "import", "浏览器上传")
|
||||
saved.append(name)
|
||||
except Exception as exc: # noqa: BLE001 - 单文件失败不影响其余文件
|
||||
errors.append({"name": name, "error": str(exc)})
|
||||
|
||||
return {
|
||||
"message": f"已上传 {len(saved)} 个文件到项目目录",
|
||||
"saved": saved,
|
||||
"errors": errors,
|
||||
"workDir": work,
|
||||
"workspace": ps.snapshot(include_messages=False),
|
||||
}
|
||||
|
||||
@app.post("/api/project-files")
|
||||
async def project_file_create(req: ProjectFileRequest) -> dict:
|
||||
from server.state.projects import get_project_store
|
||||
|
|
|
|||
|
|
@ -497,6 +497,25 @@ class ProjectStore:
|
|||
session.commit()
|
||||
return {"projectId": project_id, "name": project.name, "dataVersion": project.data_version}
|
||||
|
||||
def set_work_dir(self, project_id: str, work_dir: str) -> dict[str, Any]:
|
||||
"""设置项目工程数据目录(写权限项目)。浏览器上传本地文件后自动落位。"""
|
||||
with get_session() as session:
|
||||
self._member(session, project_id, write=True)
|
||||
project = session.execute(select(WorkspaceProject).where(
|
||||
WorkspaceProject.tenant_uuid == self.tenant_uuid,
|
||||
WorkspaceProject.id == project_id,
|
||||
WorkspaceProject.deleted == 0,
|
||||
)).scalar_one()
|
||||
project.work_dir = (work_dir or "").strip()
|
||||
project.updater_id = self.user_id
|
||||
project.updated_at = _now()
|
||||
project.data_version += 1
|
||||
self._audit(session, "project.workdir.set", "project", project_id, project_id,
|
||||
detail={"workDir": project.work_dir})
|
||||
session.commit()
|
||||
return {"projectId": project_id, "workDir": project.work_dir,
|
||||
"dataVersion": project.data_version}
|
||||
|
||||
def delete_project(self, project_id: str) -> dict[str, Any]:
|
||||
if project_id == PERSONAL_PROJECT_ID:
|
||||
raise ValueError("个人话题作用域不可删除")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
# ============================================================
|
||||
# 免登录云部署:每个浏览器访客只看到自己的项目/会话/个人世界
|
||||
# ============================================================
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def open_app(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "open.db"))
|
||||
monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json"))
|
||||
monkeypatch.setenv("APS_AUTH_ENABLED", "0")
|
||||
from server.db.database import reset_engine
|
||||
from server.state import store as world_store
|
||||
|
||||
world_store._stores.clear()
|
||||
reset_engine()
|
||||
from server.gateway.app import create_app
|
||||
|
||||
app = create_app()
|
||||
yield app
|
||||
reset_engine()
|
||||
world_store._stores.clear()
|
||||
|
||||
|
||||
def _client(app, visitor: str) -> TestClient:
|
||||
client = TestClient(app)
|
||||
client.headers["X-APS-Visitor-ID"] = visitor
|
||||
return client
|
||||
|
||||
|
||||
def test_anonymous_visitors_get_distinct_identities(open_app):
|
||||
alpha = _client(open_app, "visitor-alpha-0001")
|
||||
bravo = _client(open_app, "visitor-bravo-0002")
|
||||
|
||||
user_a = alpha.get("/api/auth/me").json()["user"]
|
||||
user_b = bravo.get("/api/auth/me").json()["user"]
|
||||
|
||||
assert user_a["auth_kind"] == "anonymous"
|
||||
assert user_a["user_id"] != user_b["user_id"]
|
||||
|
||||
|
||||
def test_anonymous_visitors_only_see_own_projects_and_sessions(open_app):
|
||||
alpha = _client(open_app, "visitor-alpha-0001")
|
||||
bravo = _client(open_app, "visitor-bravo-0002")
|
||||
|
||||
assert alpha.post(
|
||||
"/api/projects", json={"id": "proj_anon_a", "name": "访客 A 项目"},
|
||||
).status_code == 200
|
||||
|
||||
alpha_workspace = alpha.get("/api/workspace").json()
|
||||
assert [row["id"] for row in alpha_workspace["projects"]] == ["proj_anon_a"]
|
||||
|
||||
bravo_workspace = bravo.get("/api/workspace").json()
|
||||
assert bravo_workspace["projects"] == []
|
||||
assert bravo.get("/api/projects/proj_anon_a/members").status_code == 404
|
||||
|
||||
bravo_session = next(row for row in bravo_workspace["sessions"] if row["scope"] == "personal")
|
||||
assert bravo.get(f"/api/sessions/{bravo_session['id']}/messages").status_code == 200
|
||||
alpha_personal = next(
|
||||
row for row in alpha_workspace["sessions"] if row["scope"] == "personal"
|
||||
)
|
||||
assert alpha_personal["id"] != bravo_session["id"]
|
||||
assert bravo.get(f"/api/sessions/{alpha_personal['id']}/messages").status_code == 404
|
||||
|
||||
|
||||
def test_same_visitor_reuses_own_workspace(open_app):
|
||||
first = _client(open_app, "visitor-same-0001")
|
||||
first.post("/api/projects", json={"id": "proj_anon_same", "name": "同一访客项目"})
|
||||
|
||||
cookie = first.cookies.get("aps_anonymous_id")
|
||||
assert cookie
|
||||
|
||||
second = TestClient(open_app)
|
||||
second.cookies.set("aps_anonymous_id", cookie)
|
||||
workspace = second.get("/api/workspace").json()
|
||||
assert [row["id"] for row in workspace["projects"]] == ["proj_anon_same"]
|
||||
|
||||
|
||||
def test_anonymous_cookie_survives_localstorage_reset(open_app):
|
||||
first = _client(open_app, "visitor-ls-reset-0001")
|
||||
first.post("/api/projects", json={"id": "proj_anon_cookie", "name": "Cookie 项目"})
|
||||
|
||||
cookie = first.cookies.get("aps_anonymous_id")
|
||||
assert cookie
|
||||
|
||||
# 模拟 localStorage 被清空:新浏览器上下文没有访客头,只有服务端 Cookie。
|
||||
second = TestClient(open_app)
|
||||
second.cookies.set("aps_anonymous_id", cookie)
|
||||
second.headers["X-APS-Visitor-ID"] = "visitor-recreated-0001"
|
||||
workspace = second.get("/api/workspace").json()
|
||||
assert [row["id"] for row in workspace["projects"]] == ["proj_anon_cookie"]
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
# ============================================================
|
||||
# 工程目录试排(trial)只走 PoolEngine,不改变默认闭环路径
|
||||
# ============================================================
|
||||
from __future__ import annotations
|
||||
|
||||
from server.aps_domain.flex import run_flex_schedule
|
||||
from server.state.seed import empty_world
|
||||
|
||||
|
||||
class _MemStore:
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
|
||||
def next_id(self, kind: str) -> int:
|
||||
key = f"_c_{kind}"
|
||||
self.data[key] = self.data.get(key, 1000) + 1
|
||||
return self.data[key]
|
||||
|
||||
def save(self):
|
||||
pass
|
||||
|
||||
|
||||
def _site_world() -> dict:
|
||||
world = empty_world()
|
||||
world["flexOrders"] = [{
|
||||
"id": 1, "orderNo": "SO-TRIAL", "productCode": "FG", "productName": "成品",
|
||||
"quantity": 2, "dueDate": "2026-08-20", "priority": 5, "status": "RELEASED",
|
||||
}]
|
||||
world["flexMaterials"] = [{
|
||||
"id": 1, "code": "FG", "name": "成品", "type": "FINISHED_PRODUCT",
|
||||
"unit": "件", "stock": 0, "inTransit": 0, "safetyStock": 0,
|
||||
"procurementLeadTime": 0, "sourcingType": "MAKE",
|
||||
}]
|
||||
world["flexOperations"] = [{"id": 1, "code": "CUT", "name": "下料", "changeoverMin": 10}]
|
||||
world["flexRoutings"] = [{
|
||||
"id": 1, "productCode": "FG", "productName": "成品", "seq": 1,
|
||||
"operationCode": "CUT", "operationName": "下料", "stdTimePerUnit": 5.0,
|
||||
}]
|
||||
world["flexEquipment"] = [{
|
||||
"id": 1, "code": "EQ-1", "name": "切割机", "status": "RUNNING",
|
||||
"capabilities": ["CUT"], "availabilityRate": 1.0,
|
||||
}]
|
||||
return world
|
||||
|
||||
|
||||
def test_trial_pool_schedule_produces_virtual_lines():
|
||||
store = _MemStore(_site_world())
|
||||
result = run_flex_schedule(store, sort_mode="BOTTLENECK", actor="test", trial=True)
|
||||
|
||||
assert result["executionMode"] == "POOL_TRIAL"
|
||||
assert result["vlCount"] == 1
|
||||
assert result["woCount"] == 1
|
||||
version_id = result["versionId"]
|
||||
assert len([
|
||||
row for row in store.data["flexVirtualLines"]
|
||||
if row.get("versionId") == version_id
|
||||
]) == 1
|
||||
|
||||
|
||||
def test_default_run_keeps_closed_loop_mode():
|
||||
store = _MemStore(_site_world())
|
||||
result = run_flex_schedule(store, sort_mode="BOTTLENECK", actor="test")
|
||||
|
||||
assert result["executionMode"] == "CLOSED_LOOP_V1"
|
||||
|
|
@ -413,3 +413,62 @@ def test_analyze_work_dir_separates_total_errors_from_skipped_rows(tmp_path: Pat
|
|||
assert report["files"][0]["skipRows"] == 974
|
||||
assert "\u53e6\u6709 974 \u884c\u6765\u81ea\u975e\u6392\u4ea7\u5757" in report["markdown"]
|
||||
assert "\u5df2\u8df3\u8fc7\uff0c\u4e0d\u8ba1\u5165\u95ee\u9898\u884c" in report["markdown"]
|
||||
|
||||
|
||||
def test_analyze_work_dir_excludes_ok_rows_from_non_scheduling_blocks(tmp_path: Path, monkeypatch):
|
||||
"""带有效行的非排产 sheet 也不能进 batches/kindCounts(锐扬精简演示 xlsx)。"""
|
||||
workbook = tmp_path / "ruiyang.xlsx"
|
||||
workbook.write_bytes(b"folder-skip fixture")
|
||||
|
||||
class FakePS:
|
||||
def snapshot(self, include_messages=False):
|
||||
return {
|
||||
"projects": [{"id": "p-skip", "name": "Ruiyang", "workDir": str(tmp_path)}],
|
||||
"sessions": [{"id": "s-skip", "projectId": "p-skip"}],
|
||||
"files": [],
|
||||
}
|
||||
|
||||
def fake_preview_file(*_args, **_kwargs):
|
||||
return {
|
||||
"totalOk": 4,
|
||||
"totalErrors": 0,
|
||||
"diagnostics": [],
|
||||
"diagnosticCounts": {"ignored": 0, "warning": 0, "blocking": 0},
|
||||
"batches": [
|
||||
{
|
||||
"sheet": "工厂资源", "kind": "materials", "okCount": 2, "errorCount": 0,
|
||||
"okRows": [
|
||||
{"code": "BU4", "name": "工厂", "type": "RAW_MATERIAL"},
|
||||
{"code": "BJCJ", "name": "车间", "type": "RAW_MATERIAL"},
|
||||
],
|
||||
"errors": [], "warnings": [], "diagnostics": [],
|
||||
"diagnosticCounts": {"ignored": 0, "warning": 0, "blocking": 0},
|
||||
"fieldMap": [], "headersRaw": ["资源类型", "编码", "名称"],
|
||||
},
|
||||
{
|
||||
"sheet": "设备", "kind": "equipment", "okCount": 1, "errorCount": 0,
|
||||
"okRows": [{"code": "EQ-1", "name": "切割机", "capabilities": ["CUT"], "status": "RUNNING"}],
|
||||
"errors": [], "warnings": [], "diagnostics": [],
|
||||
"diagnosticCounts": {"ignored": 0, "warning": 0, "blocking": 0},
|
||||
"fieldMap": [], "headersRaw": ["设备编码", "设备名称", "能力"],
|
||||
},
|
||||
{
|
||||
"sheet": "销售订单", "kind": "orders", "okCount": 1, "errorCount": 0,
|
||||
"okRows": [{"orderNo": "SO-1", "productCode": "P-1", "quantity": 1,
|
||||
"deliveryDate": "2026-08-01"}],
|
||||
"errors": [], "warnings": [], "diagnostics": [],
|
||||
"diagnosticCounts": {"ignored": 0, "warning": 0, "blocking": 0},
|
||||
"fieldMap": [], "headersRaw": ["订单号", "产品编码", "数量", "交期"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
monkeypatch.setattr("server.state.projects.get_project_store", lambda: FakePS())
|
||||
monkeypatch.setattr("server.aps_domain.folder_pack.preview_file", fake_preview_file)
|
||||
report = analyze_work_dir(seed_world(), "s-skip")
|
||||
|
||||
assert report["totalOk"] == 2
|
||||
assert report["skippedRows"] == 2
|
||||
assert report["kindCounts"].get("materials", 0) == 0
|
||||
assert all(batch.get("sheet") != "工厂资源" for batch in report["batches"])
|
||||
assert [batch["kind"] for batch in report["batches"]] == ["equipment", "orders"]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from server.aps_domain.importers import apply_import_commit, preview_file, validate_batch
|
||||
from server.state.seed import seed_world
|
||||
from server.state.seed import empty_world, seed_world
|
||||
|
||||
|
||||
def _next_id_factory(world):
|
||||
|
|
@ -341,6 +341,37 @@ def test_order_csv_rush_column_maps_to_is_rush():
|
|||
assert row["orderNo"] == "SO-RUSH-1"
|
||||
|
||||
|
||||
def test_material_create_ids_do_not_collide_with_order_projection():
|
||||
"""订单先入库时 sync 会直接建成品;原材料 create 不得复用外部发号器的旧值。"""
|
||||
world = empty_world()
|
||||
counters: dict[str, int] = {}
|
||||
|
||||
def stale_next_id(kind: str) -> int:
|
||||
counters[kind] = counters.get(kind, 0) + 1
|
||||
return counters[kind]
|
||||
|
||||
orders = [{
|
||||
"kind": "orders", "sheet": "销售订单",
|
||||
"okRows": [{
|
||||
"orderNo": "SO-ID-1", "productCode": "FG-ID", "productName": "成品",
|
||||
"quantity": 2, "deliveryDate": "2026-08-12", "priority": 5,
|
||||
}],
|
||||
}]
|
||||
apply_import_commit(world, stale_next_id, orders)
|
||||
materials = [{
|
||||
"kind": "materials", "sheet": "物料",
|
||||
"okRows": [
|
||||
{"code": "FG-ID", "name": "成品", "type": "FINISHED_PRODUCT", "unit": "件", "stock": 0},
|
||||
{"code": "RM-ID", "name": "原料", "type": "RAW_MATERIAL", "unit": "PCS", "stock": 0},
|
||||
],
|
||||
}]
|
||||
apply_import_commit(world, stale_next_id, materials)
|
||||
|
||||
rows = world["materials"]
|
||||
assert len(rows) == len({row["id"] for row in rows})
|
||||
assert len([row for row in rows if row.get("code") == "RM-ID"]) == 1
|
||||
|
||||
|
||||
def test_generic_batches_sync_flex_bom_and_routing_for_mrp():
|
||||
"""通用 Excel 批次必须同步经典 BOM/工艺,否则 MRP 只能生成根制造需求。"""
|
||||
world = seed_world()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
# ============================================================
|
||||
# Web 本地工程数据文件上传黄金测试(AG-08 扩展)
|
||||
# 覆盖:multipart 上传 → 项目 workDir 自动落位 → 目录分析可见
|
||||
# ============================================================
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from server.aps_domain.folder_pack import analyze_work_dir
|
||||
from server.state.seed import seed_world
|
||||
from tests.auth_provider import install_test_auth
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def secure_app(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "tenant.db"))
|
||||
monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json"))
|
||||
monkeypatch.setenv("APS_DATA_DIR", str(tmp_path / "aps-data"))
|
||||
import server.state.store as world_store
|
||||
from server.db.database import reset_engine
|
||||
|
||||
install_test_auth(monkeypatch, "tenant-a-000000000000000000000001")
|
||||
world_store._stores.clear()
|
||||
reset_engine()
|
||||
from server.gateway.app import create_app
|
||||
app = create_app()
|
||||
yield app
|
||||
reset_engine()
|
||||
world_store._stores.clear()
|
||||
|
||||
|
||||
def _login(client: TestClient) -> None:
|
||||
response = client.post("/api/auth/login", json={
|
||||
"method": "password", "username": "planner", "password": "test",
|
||||
})
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_upload_local_files_then_folder_analysis(secure_app, monkeypatch):
|
||||
client = TestClient(secure_app)
|
||||
_login(client)
|
||||
created = client.post("/api/projects", json={"name": "浏览器项目"}).json()
|
||||
pid = created["project"]["id"]
|
||||
sid = created["session"]["id"]
|
||||
|
||||
csv = "订单号,客户,产品编码,数量,交期\nSO-DEMO-1,比亚迪,A0050101-00280,1,2026-08-30\n"
|
||||
response = client.post(
|
||||
f"/api/projects/{pid}/files/upload",
|
||||
files=[("files", ("订单-样例.csv", csv.encode("utf-8"), "text/csv"))],
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["saved"] == ["订单-样例.csv"]
|
||||
assert body["workDir"]
|
||||
assert os.path.isfile(os.path.join(body["workDir"], "订单-样例.csv"))
|
||||
|
||||
snap = client.get("/api/workspace").json()
|
||||
project = next(p for p in snap["projects"] if p["id"] == pid)
|
||||
assert project["workDir"] == body["workDir"]
|
||||
assert any(f["projectId"] == pid and f["name"] == "订单-样例.csv" for f in snap["files"])
|
||||
|
||||
class FakePS:
|
||||
def snapshot(self, include_messages=False):
|
||||
return {
|
||||
"projects": [{"id": pid, "name": "浏览器项目", "workDir": body["workDir"]}],
|
||||
"sessions": [{"id": sid, "projectId": pid}],
|
||||
"files": [],
|
||||
}
|
||||
|
||||
monkeypatch.setattr("server.state.projects.get_project_store", lambda: FakePS())
|
||||
report = analyze_work_dir(seed_world(), sid)
|
||||
assert report["ok"] is True
|
||||
assert any(f["name"] == "订单-样例.csv" for f in report["files"])
|
||||
|
||||
|
||||
def test_upload_rejects_unsupported_extension(secure_app):
|
||||
client = TestClient(secure_app)
|
||||
_login(client)
|
||||
created = client.post("/api/projects", json={"name": "拒绝测试"}).json()
|
||||
pid = created["project"]["id"]
|
||||
response = client.post(
|
||||
f"/api/projects/{pid}/files/upload",
|
||||
files=[("files", ("说明.docx", b"docx", "application/octet-stream"))],
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "仅支持" in response.json()["detail"]
|
||||
|
|
@ -8,6 +8,9 @@
|
|||
|
||||
1. 启动后端(8000)与前端,或启动桌面端「工业智核 APS」。
|
||||
2. 左侧「新建任务 / 新建项目」→ 工程目录选 `D:\ItemSpace\14.工业智核\锐扬\APS演示数据`。
|
||||
- 若用 **Web 端**演示:浏览器不能直接读本机文件夹,请在项目「文件」区点「上传」,
|
||||
多选本机 `.xlsx/.xlsm/.csv/.txt/.sql` 数据文件;系统自动落到服务端项目目录,
|
||||
随后第 2 节流程不变。
|
||||
3. 如需重新演示多智能体:右侧栏「多智能体编排」→ 看门狗区先点一次恢复(或调 `POST /api/mesh/reset`)。
|
||||
|
||||
## 2. 对话式全流程(三句话)
|
||||
|
|
|
|||
Loading…
Reference in New Issue