gongxiangp2/scripts/salary-grade-configuration.cjs

89 lines
6.6 KiB
JavaScript

// Explicit, revision-checked grade binding maintenance; no payroll/personnel writes.
const fields = p => ({ name:p.name, salaryRole:p.salaryRole, description:p.description, dimensions:p.dimensions, personnelPositionId:p.personnelPositionId??null, jobGrade:p.jobGrade||'' })
const canonical = value => JSON.stringify(value, (_key,item)=>item && typeof item==='object' && !Array.isArray(item) ? Object.fromEntries(Object.keys(item).sort().map(key=>[key,item[key]])) : item)
const equal = (a,b)=>canonical(a)===canonical(b)
const parentId='b9b80000-0000-4000-8000-000000001002'
function prepare(positions,{environment}={}) {
if(!['local','production'].includes(environment))throw Error('Explicit target required')
const parent=positions.find(p=>p.id===parentId)
if(!parent || parent.name!=='AI策划师-创意部' || parent.personnelPositionId)throw Error('Original planning position changed; review before preparing')
if(positions.some(p=>p.id!==parentId&&p.name==='策划师岗'))throw Error('Position name conflict')
const children=['P4','P3'].map(grade=>{
const matches=positions.filter(p=>p.name===`AI策划师-创意部-${grade}`)
if(matches.length!==1||matches[0].personnelPositionId||matches[0].personnelCount||matches[0].salaryRole!==parent.salaryRole)throw Error('Grade scheme conflict: '+grade)
return {position:matches[0],grade}
})
const operations=[{key:'parent',before:structuredClone(parent),desired:{...fields(parent),name:'策划师岗',dimensions:[]}},...children.map(({position,grade})=>({key:grade,before:structuredClone(position),desired:{...fields(position),personnelPositionId:parentId,jobGrade:grade}}))].map(o=>({...o,receipt:null,restored:null}))
return {version:'salary-grade-20261106.1',environment,preparedAt:new Date().toISOString(),operations}
}
function checkManifest(manifest,environment) {
if(manifest.version!=='salary-grade-20261106.1'||manifest.environment!==environment||!equal(manifest.operations.map(o=>o.key),['parent','P4','P3']))throw Error('Manifest mismatch')
const [parent,p4,p3]=manifest.operations
if(parent.before.id!==parentId||parent.desired.name!=='策划师岗'||parent.desired.dimensions.length||parent.desired.personnelPositionId!==null||parent.desired.jobGrade!=='')throw Error('Invalid parent change')
for(const op of [p4,p3])if(op.desired.personnelPositionId!==parentId||op.desired.jobGrade!==op.key||!equal(op.desired,{...fields(op.before),personnelPositionId:parentId,jobGrade:op.key}))throw Error('Invalid grade change')
if(!equal(parent.desired,{...fields(parent.before),name:'策划师岗',dimensions:[]}))throw Error('Unexpected parent fields')
}
async function apply(manifest, { environment, request, save }) {
checkManifest(manifest, environment)
if (manifest.operations.some(o => o.restored || o.restorePending)) throw Error('Manifest was restored; do not reapply')
// Preflight the complete set before the first write. Each PUT still checks revision atomically.
const live = (await request('GET')).positions
for (const op of manifest.operations) {
const expected = op.receipt || op.before
const current = expected ? live.find(p => p.id === expected.id) : null
if (expected && (!current || current.revision !== expected.revision || !equal(fields(current), fields(expected)))) throw Error(`Concurrent or manual change: ${op.key}`)
if (!expected && live.some(p => p.name === op.desired.name)) throw Error(`Name conflict: ${op.key}`)
}
for (const op of manifest.operations) {
if (op.receipt) continue
// Persist intent before sending. A lost response requires explicit reconciliation, never a blind retry.
if (op.pending) throw Error(`Unconfirmed previous request: ${op.key}; reconcile before retrying`)
op.pending = true; await save(manifest)
op.receipt = op.before
? await request('PUT', op.before.id, { ...op.desired, revision: op.before.revision })
: await request('POST', null, op.desired)
op.pending = false; await save(manifest)
if (!equal(fields(op.receipt), op.desired)) throw Error(`Saved configuration differs: ${op.key}`)
}
return manifest
}
async function reconcile(manifest, { environment, request, save }) {
checkManifest(manifest, environment)
const live = (await request('GET')).positions
for (const op of manifest.operations.filter(o => o.pending && !o.receipt)) {
const matches = live.filter(p => op.before ? p.id === op.before.id : p.name === op.desired.name)
if (matches.length === 1 && equal(fields(matches[0]), op.desired) && (!op.before || matches[0].revision === op.before.revision + 1)) op.receipt = matches[0]
else if (op.before ? matches.length === 1 && matches[0].revision === op.before.revision && equal(fields(matches[0]), fields(op.before)) : matches.length === 0) { /* request did not apply */ }
else throw Error(`Cannot safely reconcile: ${op.key}`)
op.pending = false; await save(manifest)
}
for (const op of manifest.operations.filter(o=>o.restorePending && !o.restored)) {
const current=live.find(p=>p.id===op.before.id)
if(current && current.revision===op.receipt.revision+1 && equal(fields(current),fields(op.before))) op.restored=current
else if(!current || current.revision!==op.receipt.revision || !equal(fields(current),fields(op.receipt))) throw Error('Cannot safely reconcile restore: '+op.key)
op.restorePending=false; await save(manifest)
}
return manifest
}
async function restore(manifest, { environment, request, save }) {
checkManifest(manifest, environment)
if (manifest.operations.some(o => o.pending || o.restorePending)) throw Error('Reconcile unconfirmed requests first')
const live = (await request('GET')).positions
for (const op of manifest.operations.filter(o => o.receipt && !o.restored)) {
const current = live.find(p => p.id === op.receipt.id)
if (!current || current.revision !== op.receipt.revision || !equal(fields(current), fields(op.receipt))) throw Error(`Restore conflict: ${op.key}`)
if (!op.before && current.personnelCount > 0) throw Error(`POSITION_HAS_PEOPLE: ${op.key}`)
}
for (const op of [...manifest.operations].reverse()) {
if (!op.receipt || op.restored) continue
const current = (await request('GET')).positions.find(p => p.id === op.receipt.id)
if (!current || current.revision !== op.receipt.revision || !equal(fields(current), fields(op.receipt))) throw Error(`Restore conflict: ${op.key}`)
op.restorePending=true; await save(manifest)
op.restored = op.before ? await request('PUT', current.id, { ...fields(op.before), revision: current.revision })
: await request('DELETE', current.id, { revision: current.revision })
op.restorePending=false; await save(manifest)
}
return manifest
}
module.exports={fields,equal,prepare,apply,reconcile,restore}