Ans.
出張デモは、行っておりません。ZOOMによるオンラインデモは、行っております。サポートまでお申しつけください。その際、「こういう仕様を実現させたい」という希望を言っていただくと、簡易実装した上でのデモとすることが出来、より実際的かと思います。 質疑応答含めて1時間~1時間半程度です。
毎月15日~25日は、混みます。可能でしたらその期間以外をご検討ください。
Ans.
出張デモは、行っておりません。ZOOMによるオンラインデモは、行っております。サポートまでお申しつけください。その際、「こういう仕様を実現させたい」という希望を言っていただくと、簡易実装した上でのデモとすることが出来、より実際的かと思います。 質疑応答含めて1時間~1時間半程度です。
毎月15日~25日は、混みます。可能でしたらその期間以外をご検討ください。
以下のように出力しました。
当直
内科拘束
整形拘束
短時間宿直シフトは、暦日通りに出力しています。スケジュールナースのシフトから補正しています。
プロジェクトの全Pythonソースです。mainのPython部(制約)が最初、post部のPythonのmain def post_main() が最後に配置することが注意点です。制約のpythonとpostのpythonは、別プロセスであることにも注意してください。通信はできません。
post処理では、shift_solutionがシフト解配列として利用可能ですので、ひたすら解を読んで上記フォーマットとなるように整形しています。タスク解(診療科)も利用可能ですが、上記フォーマット出力するには、シフト解のみで十分です。
本プロジェクトは、大変に複雑な仕様です。本コードは、そのための開発デバッグ検証用も兼ねており、開発当初から、改変を積み重ねてきました。冗長なコードになっているのはそのためです。
ユーザには、スケジュール解ではなく、開発当初より、本Excel解を提出してデバッグにご協力いただきました。元々の顧客フォーマットが上のようなものなので、違和感なく検証いただけたと思います。Excel解の提出のフェーズが完了しました。以降、スケジュールナースプロジェクトを使用した、デバッグフェーズに移行します。
import sc3
import sys
import os
import csv
import re
import win32gui, win32con
import pywin.dialogs.list
import calendar
from collections import namedtuple
for person in 非常勤:
for day in 今月:
if shift_schedules[person][day][0]=='':
v=sc3.GetShiftVar(person,day,'その他')
s='非常勤は予定入力がないときはその他 '+staffdef[person]+' '+daydef[day]
sc3.AddHard(v,s)
def get_open_file_name(title):
filter='xlsx\0*.xlsx\0'
customfilter='Other file types\0*.*\0'
fname, customfilter, flags=win32gui.GetOpenFileNameW(
InitialDir=project_file_path,
Flags=win32con.OFN_ALLOWMULTISELECT|win32con.OFN_EXPLORER,
File='', DefExt='csv',
Title=title,#
Filter=filter,
CustomFilter=customfilter,
FilterIndex=0)
return str(fname)
def draw拘束(ws,c,person,str):
if person in 内科:
ws.Cells(c.Row+3,c.Column).Value=str
elif person in 整形外科:
ws.Cells(c.Row+4,c.Column).Value=str
def day_solution_analysis(ws,c,n,day):
#clear
宿日直person=-1
日直person=-1
宿直person=-1
for person in 非常勤:
if shift_solution[person][day]=='その他':
continue
if '宿日直' in shift_solution[person][day]:
if 宿日直person!=-1:
print("fatal error 宿日直は、 most one work per a day")
exit()
宿日直person=person
continue
if '日直(日中のみ)' == shift_solution[person][day]:
if 日直person!=-1:
print("fatal error 日直は、 most one work per a day")
exit()
日直person=person
continue
if '宿直'in shift_solution[person][day]:
if 宿直person!=-1:
print("fatal error 宿直は、 at most one work per a day")
exit()
宿直person=person
continue
print("fatal error ")
exit()
list=[]
短宿person=-1
拘束宿list=[]
拘束日直list=[]
日直拘束宿日直person=-1
宿直拘束宿日直person=-1
for person in 常勤:
if shift_solution[person][day]=='その他':
continue
if shift_solution[person][day]=='宿日直':
if 宿日直person!=-1:
print("fatal error 宿日直は、 most one work per a day")
exit()
宿日直person=person
continue
if '30分'in shift_solution[person][day] or '60分' in shift_solution[person][day] or '90分' in shift_solution[person][day]:
if 短宿person!=-1:
print(staffdef[person],daydef[day])
print("fatal error 短宿は、 at most one work per a day")
exit()
短宿person=person
print(staffdef[短宿person],daydef[day],shift_solution[短宿person][day])
continue
if '日直(日中のみ)' in shift_solution[person][day] :
if 日直person!=-1:
print(staffdef[person],daydef[day])
print("fatal error 日直は、 at most one work per a day")
exit()
日直person=person
continue
if '宿直'==shift_solution[person][day] :
if 宿直person!=-1:
print("fatal error 宿直は、 at most one work per a day")
exit()
宿直person=person
continue
if '日直拘束宿日直'==shift_solution[person][day]:
if 日直拘束宿日直person!=-1:
print("fatal error 日直拘束宿日直は、 at most one work per a day")
exit()
日直拘束宿日直person=person
continue
if '宿直拘束宿日直'==shift_solution[person][day]:
if 宿直拘束宿日直person!=-1:
print("fatal error 宿直拘束宿日直は、 at most one work per a day")
exit()
宿直拘束宿日直person=person
continue
if '拘束宿'==shift_solution[person][day]:
拘束宿list.append(person)
continue
if '拘束日直'==shift_solution[person][day]:
拘束日直list.append(person)
continue
#print("list add",staffdef[person])
list.append((person,shift_solution[person][day]))
if 短宿person!=-1:#Draw 短縮
print(短宿person,staffdef[短宿person],daydef[day],n,shift_solution[短宿person][day])
str=staffdef[短宿person]+' '
if '拘束' in shift_solution[短宿person][day]:
draw拘束(ws,c,短宿person,staffdef[短宿person])
if shift_solution[短宿person][day]=='夜30分宿直' or shift_solution[短宿person][day]=='夜30分宿直拘束付':
str +='17:00~17:30'
ws.Cells(c.Row+1,c.Column+1).Value=str
elif shift_solution[短宿person][day]=='夜60分宿直' or shift_solution[短宿person][day]=='夜60分宿直拘束付':
str +='17:00~18:00'
ws.Cells(c.Row+1,c.Column+1).Value=str
elif shift_solution[短宿person][day]=='朝30分宿直' or shift_solution[短宿person][day]=='朝30分宿日直拘束付':
str +='8:00~8:30'
print("!!朝30分",staffdef[person],daydef[day],n.Row,n.Column)
if n!=None:
print(staffdef[短宿person],daydef[day])
ws.Cells(n.Row+1,n.Column+1).Value=str
elif shift_solution[短宿person][day]=='朝60分宿直' or shift_solution[短宿person][day]=='朝60分宿日直拘束付':
str +='7:30~8:30'
if n!=None:
ws.Cells(n.Row+1,n.Column+1).Value=str
print(staffdef[短宿person],daydef[day],n.Row+1,n.Column,ws.Cells(n.Row+1,n.Column).Value)
elif shift_solution[短宿person][day]=='朝90分宿直' or shift_solution[短宿person][day]=='朝90分宿日直拘束付':
str +='7:00~8:30'
if n!=None:
ws.Cells(n.Row+1,n.Column+1).Value=str
if 日直person!=-1 or 日直拘束宿日直person!=-1:
if 宿直person==-1 and 宿直拘束宿日直person==-1:
print("fatal error 日直があるなら宿直もないとだめ")
exit()
if 宿直person!=-1:
strlast=staffdef[宿直person]
else:
strlast=staffdef[宿直拘束宿日直person]
if 日直person!=-1:
str=staffdef[日直person]+'/'+strlast
ws.Cells(c.Row+2,c.Column).Value=str
if 宿直拘束宿日直person!=-1:
draw拘束(ws,c,宿直拘束宿日直person,strlast)
else:
draw拘束(ws,c,宿直person,strlast)
elif 日直拘束宿日直person!=-1:
str=staffdef[日直拘束宿日直person]+'/'+strlast
ws.Cells(c.Row+2,c.Column).Value=str
draw拘束(ws,c,日直拘束宿日直person,str)
elif 宿日直person!=-1:
str=staffdef[宿日直person]
ws.Cells(c.Row+2,c.Column).Value=str
draw拘束(ws,c,宿日直person,str)
elif 宿直person!=-1:
str=staffdef[宿直person]
ws.Cells(c.Row+2,c.Column).Value=str
draw拘束(ws,c,宿直person,str)
else:
print("fatal error ")
exit()
if len(拘束日直list)==1:
str=staffdef[拘束日直list[0]]
if 宿直person==-1 and len(拘束宿list)!=1:
print("fatal error 拘束日直があるなら、拘束宿日直もしくは宿直もないとだめ")
exit()
if 拘束日直list[0] in 内科:
if 宿直person not in 内科 and 拘束宿list[0] not in 内科:
print("fatal error")
exit()
elif 拘束日直list[0] in 整形外科:
if 宿直person not in 整形外科 and 拘束宿list[0] not in 整形外科:
print("fatal error")
exit()
if 宿直person !=-1:
str=staffdef[拘束日直list[0]]+'/'+staffdef[宿直person]
elif len(拘束宿list)==1:
str=staffdef[拘束日直list[0]]+'/'+staffdef[拘束宿list[0]]
print("拘束日直list[0]",str)
draw拘束(ws,c,拘束日直list[0],str)
elif len(拘束日直list)==2:
if 宿直person==-1 or len(拘束宿list)!=1:
print("fatal error 拘束日直があるなら、拘束宿直および宿直どちらもないとだめ")
exit()
if 拘束日直list[0] in 内科:
if 宿直person in 内科:
str=staffdef[拘束日直list[0]]+'/'+staffdef[宿直person]
draw拘束(ws,c,拘束日直list[0],str)
str=staffdef[拘束日直list[1]]+'/'+staffdef[拘束宿list[0]]
draw拘束(ws,c,拘束日直list[1],str)
elif 拘束宿list[0] in 内科:
str=staffdef[拘束日直list[0]]+'/'+staffdef[拘束宿list[0]]
draw拘束(ws,c,拘束日直list[0],str)
str=staffdef[拘束日直list[1]]+'/'+staffdef[宿直person]
draw拘束(ws,c,拘束日直list[1],str)
else:
print("fatal error 拘束日直が内科なら、拘束宿直および宿直どちらは内科")
exit()
elif 拘束日直list[0] in 整形外科:
if 宿直person in 整形外科:
str=staffdef[拘束日直list[0]]+'/'+staffdef[宿直person]
draw拘束(ws,c,拘束日直list[0],str)
str=staffdef[拘束日直list[1]]+'/'+staffdef[拘束宿list[0]]
draw拘束(ws,c,拘束日直list[1],str)
elif 拘束宿list[0] in 整形外科:
str=staffdef[拘束日直list[0]]+'/'+staffdef[拘束宿list[0]]
draw拘束(ws,c,拘束日直list[0],str)
str=staffdef[拘束日直list[1]]+'/'+staffdef[宿直person]
draw拘束(ws,c,拘束日直list[1],str)
else:
print("fatal error 拘束日直が整形外科なら、拘束宿直および宿直どちらは整形外科")
exit()
elif len(拘束宿list) >=1:
for person in 拘束宿list:
str=staffdef[person]
draw拘束(ws,c,person,str)
#draw拘束(ws,c,拘束宿person,str)
if len(list) <=2:
for tuple in list:
if '拘束宿日直' not in tuple[1]:
print(staffdef[tuple[0]],tuple[1])
print("fatal error ")
exit()
str=staffdef[tuple[0]]
print('拘束宿日直',str)
draw拘束(ws,c,tuple[0],str)
else:
print("len(list)",len(list),len(拘束宿list),len(拘束日直list))
print("fatal error programming error")
exit()
def get_sheet_name():
s=daydef[制約開始日]
year_str=s[0:4]
month=int(s[5:7])
sheet_name=year_str+'年'+str(month)+'月'
return sheet_name
def get_year():
s=daydef[制約開始日]
year_str=s[0:4]
return int(year_str)
def get_month():
s=daydef[制約開始日]
month=int(s[5:7])
return month
def get_day():
s=daydef[制約開始日]
day=int(s[8:10])
return day
def find_address(moncal,D):
row=0
for r in moncal:
if D not in r:
row+=1
continue
return (row,r.index(D))
#次月最初の日:最終行の最終列+1を出す
row=len(moncal)-1
col=len(moncal[row])
return (row,col)
raise IndexError
def init_row_columns():
global start_row
global start_column
global row_interval
global column_interval
start_row=6
start_column=2
row_interval=6
column_interval=2
def findcell(ws,moncal,D):
tuple=find_address(moncal,D)
init_row_columns()
row=start_row+row_interval*tuple[0]
column=start_column+column_interval*tuple[1]
c=namedtuple("Row","Column")
c.Row=row
c.Column=column
return c
def clear_cells(ws):
init_row_columns()
for r in range(6):
for d in range(7):
row=start_row+r*row_interval
column=start_column+d*column_interval
print(row,column)
ws.Cells(row ,column+1).Value=""
ws.Cells(row ,column).Value=""
ws.Cells(row+1,column+1).Value=""
ws.Cells(row+1,column).Value=""
ws.Cells(row+2,column+1).Value=""
ws.Cells(row+2,column).Value=""
ws.Cells(row+3,column).Value=""
ws.Cells(row+4,column).Value=""
def post_main():
print('\n\n*********ポスト処理を実行中です。*************\n')
import win32com.client#pywin32をインポート
#すでにExcelが起動されている場合はそのタスクが使われる
#エラー終了するとタスクは残ります
try :
xl = win32com.client.Dispatch("Excel.Application")
except:
print("can not invoke excel")
exit()
#動いている様子を見てみる
xl.Visible = False
#file=get_open_file_name("Open Excel File")
#print(file)
os.chdir(project_file_path)
filename="2024当直拘束表.xlsx"
file=os.path.join(project_file_path,filename)
file.replace("/","\\") #なぜか逆スラッシュでないと動かない
wb = xl.Workbooks.Open(file)
# Excelシートオブジェクト
ws = wb.Worksheets(get_sheet_name())
# 指定したシートを選択
# Select()の使用前にシートのActivate()が必要
ws.Activate()
calendar.setfirstweekday(6)#日曜日を最初に
mon_cal=calendar.monthcalendar(get_year(),get_month())#行列でリスト
print(findcell(ws,mon_cal,1))
clear_cells(ws)
for day in 今月区間:
D=day-制約開始日+1
print(D)
c=findcell(ws,mon_cal,D)
n=findcell(ws,mon_cal,D+1)
#print(c.Row,c.Column)
day_solution_analysis(ws,c,n,day)
wb.Close(True)# Trueで保存。False=Defaultでブックを保存せずにクローズ
# Excel終了
xl.Quit()
print('\n\n*********ポスト処理を実行終了しました。*************\n')
適当なテンプレートを探します。
今回は、以下のExcelブックを有難く使わせていただきます。ありがとうございます。
エクセルカレンダーテンプレート | アラクネ (arachne.jp)
ユーザフォーマットとしては、3行に渡るものが必要でした。上記フォーマットは、最大6行まで可能です。
import sc3
import sys
import os
import csv
import re
import win32gui, win32con
import datetime
import calendar
def get_sheet_name():
s=daydef[制約開始日]
year_str=s[0:4]
month=int(s[5:7])
sheet_name=year_str+'年'+str(month)+'月'
return sheet_name
def get_year():
s=daydef[制約開始日]
year_str=s[0:4]
return int(year_str)
def get_month():
s=daydef[制約開始日]
month=int(s[5:7])
return month
def get_day():
s=daydef[制約開始日]
day=int(s[8:10])
return day
def find_address(moncal,D):
row=0
for r in moncal:
if D not in r:
row+=1
continue
return (row,r.index(D))
raise IndexError
def init_row_columns():
global start_row
global start_column
global row_interval
global column_interval
start_row=5
start_column=2
row_interval=6
column_interval=2
def findcell(ws,moncal,D):
tuple=find_address(moncal,D)
init_row_columns()
row=start_row+row_interval*tuple[0]
column=start_column+column_interval*tuple[1]
return (row,column)
def clear_cells(ws):
init_row_columns()
for r in range(6):
for d in range(7):
row=start_row+r*row_interval
column=start_column+d*column_interval
print(row,column)
ws.Cells(row ,column+1).Value=""
ws.Cells(row+1,column+1).Value=""
ws.Cells(row+2,column+1).Value=""
ws.Cells(row+3,column).Value=""
ws.Cells(row+4,column).Value=""
ws.Cells(row+5,column).Value=""
def post_main():
print('\n\n*********ポスト処理を実行中です。*************\n')
import win32com.client#pywin32をインポート
#すでにExcelが起動されている場合はそのタスクが使われる
#エラー終了するとタスクは残ります
try :
excel = win32com.client.Dispatch("Excel.Application")
except:
print("can not invoke excel")
exit()
#動いている様子を見てみる
excel.Visible = True
os.chdir(project_file_path)
filename="2024勤務表.xlsx"
file=os.path.join(project_file_path,filename)
file.replace("/","\\")
sheet_name=get_sheet_name()
try:
wb = excel.Workbooks.Open(file)
# Excelシートオブジェクト
ws = wb.Worksheets(sheet_name)
except:
print("Can not open the excel file.")
wb.Close()
excel.Quit()
# 指定したシートを選択
# Select()の使用前にシートのActivate()が必要
ws.Activate()
calendar.setfirstweekday(6)#日曜日を最初に
mon_cal=calendar.monthcalendar(get_year(),get_month())#行列でリスト
print(findcell(ws,mon_cal,1))
clear_cells(ws)
wb.Close(True)# Trueで保存。False=Defaultでブックを保存せずにクローズ
# Excel終了
excel.Quit()
print('\n\n*********ポスト処理を実行終了しました。*************\n')
Q.
毎月の「当直予定表及び拘束表」の常勤医師ごとの担当実績は、当直及び拘束もそれぞれ休日または平日に分類し、実績を各月・会計年ごとに集計したい。
常勤医師の当直、宿直又は宿日直(当直)及び拘束の担当回数は、各月及び会計年度でできれば平準化したい。
Ans.
会計年の集計・平準化については、スケジュールナース仕事の対象外とさせてください。
仮に、Excelのマクロ等を用いて自動化したとしても、希望休み属性やスキル等により、どうしても不均衡が生じてしまう例はたくさんあります。そこで、敢えて自動化しない、俯瞰的公平の観点により、累計結果を見ながら、下記設定を毎月管理者が行う、という方法をお勧めしています。
具体的には、以下のスタッフプロパティシートにより、各医師の各目標回数を毎月設定します。
各月は、勿論割り切れない場合も存在するので、別にExcel累計集計によりフィードバック・フィードフォワードしながらの設定が可能になります。しかし、前述のように、各医師の希望休み志向やスキル等により、どうしてもフラットにはならないケースが出てきます。(例えば、前回、休日の拘束出勤2連勤は、若手医師がやらざるを得ないケースがありましたが、そのような希望休み数が少ない方が居て初めて解が存在する場合があります。)
何を持って平等とするか、あるいは、経年数による傾斜配分等、人事・人間関係に及ぶので、複雑で難しい問題が待っています。例えば、以下のような俯瞰的に判断するケースが出てくると思います。
人生初の勤務表作成 公平性と家庭の事情のハザマ (nurse-scheduling-software.com)
単純なプログラムで対応可能なケースはむしろまれで、管理者が月毎に判断設定した方が実際的である、と思います。
なお、お客さまのExcelフォーマットに解を出力させるには、PythonPost処理で行うことが可能です。次に説明します。
Q.
拘束のみで休日の25日、26日と2日連続している点が気になります。ただし、こちらも、実際の予定表にあるケースで、さらに、今回は、実際の予定表がこれと同じになっていますが、休日は拘束のみであれば連続していない方が望ましいです。
Ans.
実装しましたが、今回の例では、回避することが出来ず同じになります。
証明は、以下に示しますが、結論的には、予定をハード制約と考えると、物理的必然となるからです。
<実装>
以下が追加した制約になります。
<解>
依然として休日2連勤拘束のままです。
<ハード制約化>
言うことを聞かないときの最終手段は、ハード制約化です。ソフト制約のレベルを空白にするとハード制約となります。
<解>
すると次のようなエラーとなりました。この状態は、解がないことを示しています。解がない理由は、ハード制約同士に矛盾があるからです。矛盾要因は通常、複数あります。要因がヒントとして列挙されています。
特に、茶色枠部に着目します。クリックすると当該予定にマークが付き飛びます。
<解析>
以下の予定を見ると、X大医師の当直があり、なおかつ他の整形の先生方が両日共休みになっています。
他の整形の先生方は、全てハード予定休みであり、かつX大医師が2日共、宿・当直ですので、希望を出していないX先生が2日共、拘束勤務を行うしかない状況です。つまり、
■X先生が2日共、拘束をやる
選択一択しかありません。しかし、ハード制約で2連拘束休日禁止にしたので、矛盾となり解が存在しません。
以上、ハード制約化することにより、要因が列挙され原因が特定できました。
予定をセル毎に任意のレベルのソフト制約化すれば、回避できますが、予定はハード制約のまま、制約もソフト制約にしておいて、必要がある場合のみ上記のような解析を行うのが通常です。いずれにせよ、このシステムを使いこなすには、制約に対する深い理解が必要である、ということです。GUI操作自体は、誰でも出来ますが、エラーメッセージを理解して原因を理解するには、ハード制約とソフト制約を理解する必要があります。
また、今回追加実装した程度の制約追加は、造作ないことですので、ユーザ様ご自身が必要に応じて追加することも可能です。
Q.x医師の拘束が25日及び26日の連続となっている。24日の宿直も含めれば
3日連続となっている。
対策案
ソフト制約として、「拘束は、当直、宿直又は宿日直による拘束の兼務と
当直、宿直又は宿日直の終了日が休日の場合の拘束の連続以外は、連続しな
いのが望ましい。」を追加する。
Ans.
既に、拘束2連勤禁止は、ソフト制約の高い重みで入っていたのですが、組み合わせ最適化の結果としてそういう解になっていました。ご要望の仕様も実装が可能と思いますが、簡単な実装という意味で、当直・拘束3連勤禁止として、追加実装しております。
常Anyラベルは、以下のシフト集合です。短時間夜勤とその他以外の全ての勤務となりますから、一般の宿直関係と拘束を全て含みます。
こちらは自治体病院ということもあり、発注の仕方も限られてきてご迷惑を
おかけしますが、もう少し具体的に教えて下さい。
こちらの都合では、例えば年間の契約料を委託料としてお支払するには、作業項目
毎の金額の入った見積書をいただき、それに基づいて契約を取り交わして、
委託料をお支払するのが良いような気がいたしますが、これまでのことなども
あると思いますので、どうすれば良いのか教えてください。
Ans.
既に、プロジェクトサービスをご利用中ですので、以下のストア買い切り版+プロジェクト作成サービス一択になります。
https://www.nurse-scheduling-software.com/japanese/service2/onetime_purchase/
国公立大学病院、地方自治体で、ご利用頂いておりますが、全てこの形態です。
基本的には、1年の間に使いこなして頂いて、後はサポート不要という形まで持って行く、のが望ましい姿になります。なお、物理的には、同時使用10台可能ですが、ライセンスは、一部署一ライセンスとなります。(Ex.看護部で使用するには別ライセンスが必要になります。)
今まで上記のようなご要求を頂いたことはなく、ご容赦頂きたいと思います。
もしも将来、「プロジェクトを大変更したいので作業を依頼したい」、という場合は、その都度お見積りしますが、最悪、今回サービスの再発注になるとお考え頂ければよいかと思います。