494 amount: selected ? Number(selected.amount_cents || 0) / 100 : 0, 495 inherited: Boolean(selected && clean(selected.scope_type || "global").toLowerCase() === "global"), 496 unit: definition.unit, 497 label: definition.label, 498 effective_from: dateOnly(selected?.starts_at) || null, 499 scheduled: Boolean(dateOnly(selected?.starts_at) && dateOnly(selected?.starts_at) > today()), 500 }; 501 } 502 return { ok: true, centre_id: centreId, costs }; 503} 504 505async function listScopedRules(context, table, centreId) { 506 const [globalResult, centreResult] = await Promise.all([ 507 context.call("tables", "rows_search", { 508 table, 509 where: [{ col: "scope_type", op: "eq", value: "global" }], 510 order_by: "id desc", 511 limit: 500, 512 }), 513 context.call("tables", "rows_search", { 514 table, 515 where: [ 516 { col: "scope_type", op: "eq", value: "centre" }, 517 { col: "scope_id", op: "eq", value: centreId }, 518 ], 519 order_by: "id desc", 520 limit: 500, 521 }), 522 ]); 523 return { rows: [...(globalResult?.rows || []), ...(centreResult?.rows || [])] }; 524} 525 526async function saveCosts(context, centreId, input, existingRows = [], effectiveFrom) { 527 const now = new Date().toISOString(); 528 const pending = pendingRuleChanges(); 529 for (const [costType, definition] of Object.entries(COST_DEFINITIONS)) { 530 const amount = Number(input?.[costType]); 531 if (!Number.isFinite(amount) || amount < 0 || amount > 100_000) { 532 throw new Error(`Montant invalide pour ${definition.label}.`); 533 } 534 const localId = `centre:${centreId}:${costType}:${definition.unit}`; 535 const fields = { 536 scope_type: "centre", 537 scope_id: centreId, 538 cost_type: costType, 539 amount_cents: Math.round(amount * 100), 540 currency: "EUR", 541 unit: definition.unit, 542 cost_owner: "centre", 543 active: true, 544 starts_at: ruleDateTime(effectiveFrom), 545 ends_at: null, 546 updated_at_iso: now, 547 }; 548 versionRule(pending, existingRows, { 549 baseLocalId: localId, 550 effectiveFrom, 551 now, 552 matches: (row) => clean(row.scope_type) === "centre" 553 && clean(row.scope_id) === centreId 554 && clean(row.cost_type) === costType 555 && clean(row.unit) === definition.unit, 556 fields, 557 }); 558 } 559 await applyRuleChanges(context, "production_cost_rules", pending); 560} 561 562function pendingRuleChanges() { 563 return { updates: [], inserts: [] }; 564} 565 566function versionRule(pending, existingRows, { baseLocalId, effectiveFrom, now, matches, fields }) { 567 const candidates = existingRows.filter(matches).filter(isActiveRow); 568 const sameStart = candidates.find((row) => dateOnly(row.starts_at) === effectiveFrom); 569 if (sameStart?.id) { 570 pending.updates.push({ id: Number(sameStart.id), fields }); 571 Object.assign(sameStart, fields); 572 return; 573 } 574 575 const previousDay = shiftDate(effectiveFrom, -1); 576 for (const row of candidates) { 577 const starts = dateOnly(row.starts_at); 578 if (starts && starts > effectiveFrom) { 579 pending.updates.push({ id: Number(row.id), fields: { active: false, updated_at_iso: now } }); 580 row.active = false; 581 continue; 582 } 583 if ((!starts || starts < effectiveFrom) && (!dateOnly(row.ends_at) || dateOnly(row.ends_at) >= effectiveFrom)) { 584 pending.updates.push({ id: Number(row.id), fields: { ends_at: ruleDateTime(previousDay), updated_at_iso: now } }); 585 row.ends_at = previousDay; 586 } 587 } 588 589 const version = `${baseLocalId}:${effectiveFrom.replaceAll("-", "")}`; 590 const inserted = { local_id: version, ...fields, created_at_iso: now }; 591 pending.inserts.push(inserted); 592 existingRows.push(inserted); 593} 594 595async function applyRuleChanges(context, table, pending) { 596 await runWithConcurrency( 597 pending.updates.map((update) => () => context.call("tables", "rows_update", { table, ...update })), 598 6, 599 ); 600 if (pending.inserts.length) {